Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
23db57b
feat: support sparse manifest.json in state files
PaulHax Nov 29, 2025
7a7445e
fix: report errors and support multiple volumes in state file restora…
PaulHax Nov 30, 2025
bdb7ad7
fix: unify state file restoration with main import pipeline
PaulHax Nov 30, 2025
024b5de
refactor(e2e): consolidate test utilities and fix slow state-manifest…
PaulHax Nov 30, 2025
274197d
refactor: extract state file migrations to dedicated module
PaulHax Nov 30, 2025
431373d
refactor: simplify state file data source restoration
PaulHax Nov 30, 2025
c926056
refactor: replace state file callbacks with result-based flow
PaulHax Nov 30, 2025
5ace1a7
fix: prevent VTK crash when rendering images without scalar data
PaulHax Nov 30, 2025
a62c0f4
chore(e2e): speed up tests
PaulHax Nov 30, 2025
bb0e5f2
test(e2e): add sparse manifest test for prostate rectangle with lesio…
PaulHax Nov 30, 2025
9613f05
fix(state): bump manifest version to 6.1.1 for sparse schema support
PaulHax Dec 1, 2025
9d455cf
feat(state): support standalone JSON state files without zip wrapper
PaulHax Dec 1, 2025
97f32b0
fix(paint): use labelmap's coordinate transform
PaulHax Dec 1, 2025
d8b28d7
fix(segmentation): handle labelmaps with different orientations
PaulHax Dec 2, 2025
5fadbc7
feat(state): support remote URIs for segment groups
PaulHax Dec 2, 2025
d349c87
test: add regression test for labelmap with different direction matrix
PaulHax Dec 2, 2025
55a57e3
fix(state): use primarySelection when restoring view data
PaulHax Dec 2, 2025
d3be4ab
fix(urlParams): don't split URLs containing commas in query strings
PaulHax Dec 2, 2025
8384372
feat(labels): put each label on own row
PaulHax Dec 3, 2025
507304a
fix(tools): keep segment groups from covering rect and poly fills
PaulHax Dec 3, 2025
5d8665a
fix(tools): show hover labels while placing annotation tools
PaulHax Dec 3, 2025
5a3df82
refactor: rename labelMaps to segmentGroups in state file schema
PaulHax Dec 3, 2025
564f8d6
refactor: rename state-file/index.ts to serialize.ts
PaulHax Dec 3, 2025
dfb4b17
refactor: replace session-zip test with session-state-lifecycle
PaulHax Dec 3, 2025
a2819a7
refactor(state): drop support for state files v3.0.0 and earlier
PaulHax Dec 3, 2025
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
2 changes: 1 addition & 1 deletion .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
name: E2E Testing on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
env:
DOWNLOAD_TIMEOUT: 220000
DOWNLOAD_TIMEOUT: 60000
VITE_SHOW_SAMPLE_DATA: true
steps:
- uses: actions/checkout@v4
Expand Down
9 changes: 5 additions & 4 deletions package-lock.json

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

8 changes: 6 additions & 2 deletions src/components/EditableChipList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ const itemsToRender = computed(() =>
mandatory
>
<v-row dense>
<v-col cols="6" v-for="({ key, title }, idx) in itemsToRender" :key="key">
<v-col
cols="12"
v-for="({ key, title }, idx) in itemsToRender"
:key="key"
>
<v-item v-slot="{ selectedClass, toggle }" :value="key">
<v-chip
variant="tonal"
Expand All @@ -59,7 +63,7 @@ const itemsToRender = computed(() =>
</v-col>

<!-- Add Label button -->
<v-col cols="6">
<v-col cols="12">
<v-chip variant="outlined" class="w-100" @click="$emit('create')">
<v-icon class="mr-2">mdi-plus</v-icon>
{{ createLabelText }}
Expand Down
1 change: 1 addition & 0 deletions src/components/ModulePanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ export default defineComponent({
position: relative;
flex: 2;
overflow: auto;
scrollbar-gutter: stable;
}

.module-text {
Expand Down
2 changes: 1 addition & 1 deletion src/components/SaveSession.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { defineComponent, onMounted, ref } from 'vue';
import { saveAs } from 'file-saver';
import { onKeyDown } from '@vueuse/core';

import { serialize } from '../io/state-file';
import { serialize } from '../io/state-file/serialize';

const DEFAULT_FILENAME = 'session.volview.zip';

Expand Down
69 changes: 48 additions & 21 deletions src/components/tools/ScalarProbe.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { inject, watch, computed, toRefs } from 'vue';
import type { ReadonlyVec3 } from 'gl-matrix';
import { vec3 } from 'gl-matrix';
import { onVTKEvent } from '@/src/composables/onVTKEvent';
import { worldPointToIndex } from '@/src/utils/imageSpace';
import { VtkViewContext } from '@/src/components/vtk/context';
import { useCurrentImage } from '@/src/composables/useCurrentImage';
import vtkPointPicker from '@kitware/vtk.js/Rendering/Core/PointPicker';
Expand Down Expand Up @@ -110,29 +112,54 @@ const getImageSamples = (x: number, y: number) => {
pointPicker.pick([x, y, 1.0], view.renderer);
if (pointPicker.getActors().length === 0) return undefined;

const ijk = pointPicker.getPointIJK() as unknown as ReadonlyVec3;
const samples = sampleSet.value.map((item: any) => {
const dims = item.image.getDimensions();
const scalarData = item.image.getPointData().getScalars();
const index = dims[0] * dims[1] * ijk[2] + dims[0] * ijk[1] + ijk[0];
const scalars = scalarData.getTuple(index) as number[];
const baseInfo = { id: item.id, name: item.name };

if (item.type === 'segmentGroup') {
return {
...baseInfo,
displayValues: scalars.map(
(v) => item.segments.byValue[v]?.name || 'Background'
),
};
}
return { ...baseInfo, displayValues: scalars };
});

const position = firstToSample.image.indexToWorld(ijk);
// Get world position from the picked point
const pickedIjk = pointPicker.getPointIJK() as unknown as ReadonlyVec3;
const worldPosition = vec3.clone(
firstToSample.image.indexToWorld(pickedIjk) as vec3
);

const samples = sampleSet.value
.map((item: any) => {
// Convert world position to this specific image's IJK
const itemIjk = worldPointToIndex(item.image, worldPosition);
const dims = item.image.getDimensions();
const scalarData = item.image.getPointData().getScalars();

// Round to nearest integer indices
const i = Math.round(itemIjk[0]);
const j = Math.round(itemIjk[1]);
const k = Math.round(itemIjk[2]);

// Check bounds
if (
i < 0 ||
j < 0 ||
k < 0 ||
i >= dims[0] ||
j >= dims[1] ||
k >= dims[2]
) {
return null;
}

const index = dims[0] * dims[1] * k + dims[0] * j + i;
const scalars = scalarData.getTuple(index) as number[];
const baseInfo = { id: item.id, name: item.name };

if (item.type === 'segmentGroup') {
return {
...baseInfo,
displayValues: scalars.map(
(v) => item.segments.byValue[v]?.name || 'Background'
),
};
}
return { ...baseInfo, displayValues: scalars };
})
.filter((s): s is NonNullable<typeof s> => s !== null);

return {
pos: position,
pos: worldPosition,
samples,
};
};
Expand Down
76 changes: 41 additions & 35 deletions src/components/tools/paint/PaintWidget2D.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ import { getLPSAxisFromDir } from '@/src/utils/lps';
import { useImage } from '@/src/composables/useCurrentImage';
import { updatePlaneManipulatorFor2DView } from '@/src/utils/manipulators';
import { usePaintToolStore } from '@/src/store/tools/paint';
import { useSegmentGroupStore } from '@/src/store/segmentGroups';
import { vtkPaintViewWidget } from '@/src/vtk/PaintWidget';
import { LPSAxisDir } from '@/src/types/lps';
import { getLPSDirections } from '@/src/utils/lps';
import { onVTKEvent } from '@/src/composables/onVTKEvent';
import { useSliceInfo } from '@/src/composables/useSliceInfo';
import { VtkViewContext } from '@/src/components/vtk/context';
Expand Down Expand Up @@ -48,6 +50,7 @@ export default defineComponent({
const slice = computed(() => sliceInfo.value?.slice);

const paintStore = usePaintToolStore();
const segmentGroupStore = useSegmentGroupStore();
const widgetFactory = paintStore.getWidgetFactory();
const widgetState = widgetFactory.getWidgetState();

Expand All @@ -57,32 +60,34 @@ export default defineComponent({
() => imageMetadata.value.lpsOrientation[viewAxis.value]
);

const worldPointToIndex = (worldPoint: vec3) => {
const { worldToIndex } = imageMetadata.value;
const indexPoint = vec3.create();
vec3.transformMat4(indexPoint, worldPoint, worldToIndex);
return indexPoint;
};
// Get the active labelmap for coordinate transforms
const activeLabelmap = computed(() => {
const groupId = paintStore.activeSegmentGroupID;
if (!groupId) return null;
return segmentGroupStore.dataIndex[groupId] ?? null;
});

const widget = view.widgetManager.addWidget(
widgetFactory
) as vtkPaintViewWidget;

onMounted(() => {
view.widgetManager.renderWidgets();
view.widgetManager.grabFocus(widget);
});

onUnmounted(() => {
view.widgetManager.removeWidget(widgetFactory);
});

// --- widget representation config --- //

watchEffect(() => {
const metadata = imageMetadata.value;
const slicingIndex = metadata.lpsOrientation[viewAxis.value];
if (widget) {
if (!widget) return;

const labelmap = activeLabelmap.value;
if (labelmap) {
// Use labelmap's transforms so brush preview matches where paint appears
const labelmapLps = getLPSDirections(labelmap.getDirection());
const slicingIndex = labelmapLps[viewAxis.value];
widget.setSlicingIndex(slicingIndex);
widget.setIndexToWorld(labelmap.getIndexToWorld());
widget.setWorldToIndex(labelmap.getWorldToIndex());
} else {
// Fall back to parent image transforms
const metadata = imageMetadata.value;
const slicingIndex = metadata.lpsOrientation[viewAxis.value];
widget.setSlicingIndex(slicingIndex);
widget.setIndexToWorld(metadata.indexToWorld);
widget.setWorldToIndex(metadata.worldToIndex);
Expand All @@ -95,17 +100,19 @@ export default defineComponent({
if (!imageId.value) return;
paintStore.setSliceAxis(viewAxisIndex.value, imageId.value);
const origin = widgetState.getBrush().getOrigin()!;
const indexPoint = worldPointToIndex(origin);
paintStore.startStroke(indexPoint, viewAxisIndex.value, imageId.value);
paintStore.startStroke(
vec3.clone(origin),
viewAxisIndex.value,
imageId.value
);
paintStore.updatePaintPosition(origin, viewId.value);
});

onVTKEvent(widget, 'onInteractionEvent', () => {
if (!imageId.value) return;
const origin = widgetState.getBrush().getOrigin()!;
const indexPoint = worldPointToIndex(origin);
paintStore.placeStrokePoint(
indexPoint,
vec3.clone(origin),
viewAxisIndex.value,
imageId.value
);
Expand All @@ -114,8 +121,11 @@ export default defineComponent({

onVTKEvent(widget, 'onEndInteractionEvent', () => {
if (!imageId.value) return;
const indexPoint = worldPointToIndex(widgetState.getBrush().getOrigin()!);
paintStore.endStroke(indexPoint, viewAxisIndex.value, imageId.value);
paintStore.endStroke(
vec3.clone(widgetState.getBrush().getOrigin()!),
viewAxisIndex.value,
imageId.value
);
});

// --- manipulator --- //
Expand All @@ -137,19 +147,10 @@ export default defineComponent({

let checkIfPointerInView = false;

onMounted(() => {
widget.setVisibility(false);
checkIfPointerInView = true;
});

// Turn on widget visibility and update stencil
// if mouse starts within view
// Turn on widget visibility and update stencil if mouse starts within view
onVTKEvent(view.interactor, 'onMouseMove', () => {
if (!checkIfPointerInView) {
return;
}
if (!checkIfPointerInView) return;
checkIfPointerInView = false;

widget.setVisibility(true);
if (imageId.value) {
paintStore.setSliceAxis(viewAxisIndex.value, imageId.value);
Expand Down Expand Up @@ -186,12 +187,17 @@ export default defineComponent({
};

onMounted(() => {
view.widgetManager.renderWidgets();
view.widgetManager.grabFocus(widget);
widget.setVisibility(false);
checkIfPointerInView = true;
view.renderWindowView
.getContainer()
?.addEventListener('wheel', handleWheelEvent, { passive: false });
});

onUnmounted(() => {
view.widgetManager.removeWidget(widgetFactory);
view.renderWindowView
.getContainer()
?.removeEventListener('wheel', handleWheelEvent);
Expand Down
35 changes: 26 additions & 9 deletions src/components/tools/polygon/PolygonTool.vue
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ import type { IGrid2D } from '@thi.ng/api';
import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
import type { Vector2, Vector3 } from '@kitware/vtk.js/types';
import { containsPoint } from '@kitware/vtk.js/Common/DataModel/BoundingBox';
import { convertSliceIndex } from '@/src/utils/imageSpace';
import { getLPSDirections } from '@/src/utils/lps';
import { type ToolID } from '@/src/types/annotation-tool';
import PolygonWidget2D from '@/src/components/tools/polygon/PolygonWidget2D.vue';
import { usePaintToolStore } from '@/src/store/tools/paint';
Expand Down Expand Up @@ -177,7 +179,6 @@ export default defineComponent({

const sliceInfo = useSliceInfo(viewId, imageId);
const slice = computed(() => sliceInfo.value?.slice ?? 0);
const sliceAxis = computed(() => sliceInfo.value?.axisIndex ?? 0);

const { metadata: imageMetadata } = useImage(imageId);
const isToolActive = computed(() => toolStore.currentTool === toolType);
Expand Down Expand Up @@ -279,25 +280,41 @@ export default defineComponent({
paintStore.setActiveSegment(segment.value);
}

const image = segmentGroupStore.dataIndex[segmentGroupID];
if (!image) {
const segmentGroup = segmentGroupStore.dataIndex[segmentGroupID];
if (!segmentGroup) {
throw new Error(
`Failed to get labelmap for segment group ${segmentGroupID}`
`Failed to get segment group data for ${segmentGroupID}`
);
}

// Convert parent slice index to segment group slice index
const parentMeta = imageMetadata.value;
const segmentGroupSlice = convertSliceIndex(
slice.value,
parentMeta.lpsOrientation,
parentMeta.indexToWorld,
segmentGroup,
viewAxis.value
);

const points = activeToolStore.getPoints(toolId);
const axis = sliceAxis.value;
const segmentGroupIjkIndex = getLPSDirections(
segmentGroup.getDirection()
)[viewAxis.value];

const indexSpacePoints2D = points.map((pt) => {
const output = [...image.worldToIndex(pt)];
output.splice(axis, 1);
const output = [...segmentGroup.worldToIndex(pt)];
output.splice(segmentGroupIjkIndex, 1);
return output as Vector2;
});

const grid = createGridAccessor(image, slice.value, axis);
const grid = createGridAccessor(
segmentGroup,
segmentGroupSlice,
segmentGroupIjkIndex
);
fillPoly(grid, indexSpacePoints2D, segment.value);
image.modified();
segmentGroup.modified();
}

return {
Expand Down
Loading
Loading