From d3ad29141db3eeffef0516e9c8bdefce37bcf058 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Thu, 29 Oct 2026 10:12:23 -0400 Subject: [PATCH] feat: add reusable segment definitions --- docs/configuration_file.md | 47 +- docs/quick_start_guide.md | 2 +- docs/toolbar.md | 16 +- .../__tests__/loadImageSegmentSets.spec.ts | 492 ++++++++++++++++++ .../operatingSystemMetadataImports.spec.ts | 113 ++++ src/actions/loadUserFiles.ts | 71 +-- src/components/ControlsStripTools.vue | 5 +- src/components/EditableItemList.vue | 43 +- src/components/ImageDataBrowser.vue | 2 + src/components/LayoutGridItem.vue | 2 +- src/components/MeasurementsToolList.vue | 1 + .../__tests__/EditableItemList.spec.ts | 36 ++ .../__tests__/MeasurementsToolList.spec.ts | 49 +- src/components/tools/paint/PaintWidget2D.vue | 37 +- src/components/tools/polygon/PolygonTool.vue | 55 +- .../polygon/__tests__/PolygonTool.spec.ts | 131 +++++ .../tools/rectangle/RectangleTool.vue | 7 +- src/components/tools/ruler/RulerTool.vue | 9 +- src/components/tools/ruler/RulerWidget2D.vue | 8 +- .../__tests__/annotationTool.spec.ts | 28 +- src/composables/actions.ts | 14 +- src/composables/annotationTool.ts | 4 +- src/enface/__tests__/controls.spec.ts | 4 +- src/enface/__tests__/inputCalibration.spec.ts | 2 +- src/enface/__tests__/restoreSelection.spec.ts | 5 +- .../__tests__/thinRegionAppearance.spec.ts | 8 +- src/io/fileName.ts | 9 + src/io/import/__tests__/configJson.spec.ts | 22 +- .../crossImageAnnotationNotice.spec.ts | 87 ++++ .../__tests__/postStateSegmentConfig.spec.ts | 47 +- .../__tests__/repeatedSegmentIds.spec.ts | 48 ++ .../__tests__/restoreStateIdCollision.spec.ts | 3 +- src/io/import/configJson.ts | 10 +- src/io/import/dataSource.ts | 13 + src/io/import/importDataSources.ts | 117 ++++- src/io/import/processors/handleConfig.ts | 7 +- src/io/import/processors/restoreStateFile.ts | 46 +- .../__tests__/annotationSaveSnapshot.spec.ts | 6 +- .../__tests__/annotationToolSource.spec.ts | 3 +- .../__tests__/boundedMaskRoundTrip.spec.ts | 24 +- .../__tests__/droppedMaskReport.spec.ts | 21 +- .../imageSegmentSetsRoundTrip.spec.ts | 263 ++++++++++ .../manyToOneImageSegmentRestore.spec.ts | 269 ++++++++++ .../__tests__/maskIoConcurrency.spec.ts | 11 +- .../__tests__/migrate640To700.spec.ts | 33 +- .../__tests__/migrate700To800.spec.ts | 227 ++++++++ .../segmentationArtifactSource.spec.ts | 6 +- .../segmentationDisplayRoundTrip.spec.ts | 26 +- .../__tests__/segmentationRoundTrip.spec.ts | 23 +- .../__tests__/serializeResilience.spec.ts | 29 +- src/io/state-file/migrations.ts | 132 ++++- src/io/state-file/schema.ts | 18 +- src/io/state-file/serialize.ts | 5 +- src/io/zip.ts | 7 +- .../applyResults.annotations.spec.ts | 69 ++- .../__tests__/applyResults.segments.spec.ts | 38 +- .../__tests__/declaredEmptySegments.spec.ts | 4 +- src/processing/__tests__/job-adoption.spec.ts | 6 +- .../__tests__/segmentationPacking.spec.ts | 7 +- src/processing/__tests__/serializedScene.ts | 17 +- src/processing/applyResults.ts | 10 +- src/processing/components/JobsModule.vue | 5 +- .../components/__tests__/JobsModule.spec.ts | 8 +- src/processing/composables/useInputStaging.ts | 2 +- src/processing/segmentationInput.ts | 7 +- src/segmentation/README.md | 61 ++- .../__tests__/annotationToolSegments.spec.ts | 57 +- .../__tests__/artifactParentRestore.spec.ts | 10 +- .../__tests__/bulkImageRemoval.spec.ts | 205 ++++++++ .../__tests__/configSegments.spec.ts | 157 +++++- .../convertParentRemovedMidImport.spec.ts | 4 +- .../__tests__/deleteSegment.spec.ts | 4 +- .../descriptorlessActiveSegment.spec.ts | 9 +- src/segmentation/__tests__/fillHoles.spec.ts | 4 +- .../__tests__/findMaskVoxels.spec.ts | 2 +- .../__tests__/imageSetInitialization.spec.ts | 415 +++++++++++++++ .../__tests__/lastImageRemoval.spec.ts | 82 +-- .../lockedEditCreatesNothing.spec.ts | 12 +- .../__tests__/maskAndNameLookups.spec.ts | 6 +- .../__tests__/paintProcess.spec.ts | 17 +- .../__tests__/paintProcessStorage.spec.ts | 2 +- .../__tests__/paintTarget.spec.ts | 12 +- .../__tests__/restoreSegmentLifetime.spec.ts | 21 +- .../__tests__/segmentDisplayState.spec.ts | 30 +- .../__tests__/segmentImportPath.spec.ts | 15 +- .../__tests__/segmentMaskFixtures.ts | 29 +- .../__tests__/segmentRegistry.spec.ts | 450 ++++++++++++---- .../segmentSetAppearanceRestore.spec.ts | 167 ++++++ .../segmentationRestoreResilience.spec.ts | 3 +- .../__tests__/segmentations.spec.ts | 103 ++-- .../sharedSegmentAcrossImages.spec.ts | 137 +++-- .../components/SegmentAssignmentList.vue | 4 +- src/segmentation/components/SegmentEditor.vue | 4 +- src/segmentation/components/SegmentList.vue | 91 +++- .../components/SegmentListActions.vue | 4 +- .../__tests__/ProcessWorkflow.spec.ts | 2 +- .../__tests__/SaveSegmentationDialog.spec.ts | 2 +- .../__tests__/SegmentAssignmentList.spec.ts | 16 +- .../__tests__/SegmentEditor.spec.ts | 6 +- .../components/__tests__/SegmentList.spec.ts | 241 ++++++--- .../__tests__/useSegmentEditing.spec.ts | 26 +- .../__tests__/useSegmentShapes.spec.ts | 10 +- .../composables/useSegmentEditing.ts | 30 +- .../composables/useSegmentShapes.ts | 1 + .../__tests__/rasterizePolygon.spec.ts | 24 +- src/segmentation/editing/paintProcess.ts | 4 +- src/segmentation/editing/rasterizePolygon.ts | 9 +- src/segmentation/io/stateFile.ts | 20 +- src/segmentation/segment.ts | 4 +- src/segmentation/segmentRegistry.ts | 443 ++++++++++++---- src/segmentation/segments.ts | 248 ++++++++- src/segmentation/store.ts | 43 +- .../__tests__/datasetRemoveCascade.spec.ts | 17 +- .../legacyManifestSegmentGroups.spec.ts | 37 +- src/store/__tests__/rulers.spec.ts | 4 +- src/store/tools/index.ts | 41 +- src/store/tools/paint.ts | 3 +- src/store/tools/polygons.ts | 6 +- src/store/tools/rectangles.ts | 2 +- src/store/tools/rulers.ts | 2 +- src/store/tools/types.ts | 7 +- src/store/tools/useAnnotationTool.ts | 76 ++- tests/specs/annotations-sidebar.e2e.ts | 13 +- tests/specs/clear-scene-segments.e2e.ts | 3 +- tests/specs/image-segmentation-loading.e2e.ts | 330 ++++++++++++ tests/specs/image-segmentation-sets.e2e.ts | 378 ++++++++++++++ tests/specs/paint-brush-size.e2e.ts | 6 + tests/specs/paint-preview-color.e2e.ts | 103 ++++ tests/specs/polygon-rasterize-segment.e2e.ts | 12 +- .../specs/restore-binds-segment-names.e2e.ts | 22 +- tests/specs/segment-shared-type.e2e.ts | 117 ----- tests/specs/segment-shortcuts.e2e.ts | 68 ++- tests/specs/segment-tooltips.e2e.ts | 36 ++ tests/specs/segmentationTestUtils.ts | 29 ++ tests/specs/session-state-lifecycle.e2e.ts | 2 +- tests/specs/utils.ts | 14 + 136 files changed, 6216 insertions(+), 1234 deletions(-) create mode 100644 src/actions/__tests__/loadImageSegmentSets.spec.ts create mode 100755 src/actions/__tests__/operatingSystemMetadataImports.spec.ts create mode 100644 src/components/__tests__/EditableItemList.spec.ts create mode 100644 src/components/tools/polygon/__tests__/PolygonTool.spec.ts create mode 100644 src/io/import/__tests__/crossImageAnnotationNotice.spec.ts create mode 100644 src/io/state-file/__tests__/imageSegmentSetsRoundTrip.spec.ts create mode 100644 src/io/state-file/__tests__/manyToOneImageSegmentRestore.spec.ts create mode 100644 src/io/state-file/__tests__/migrate700To800.spec.ts create mode 100644 src/segmentation/__tests__/bulkImageRemoval.spec.ts create mode 100644 src/segmentation/__tests__/imageSetInitialization.spec.ts create mode 100644 src/segmentation/__tests__/segmentSetAppearanceRestore.spec.ts create mode 100644 tests/specs/image-segmentation-loading.e2e.ts create mode 100644 tests/specs/image-segmentation-sets.e2e.ts create mode 100644 tests/specs/paint-preview-color.e2e.ts delete mode 100644 tests/specs/segment-shared-type.e2e.ts diff --git a/docs/configuration_file.md b/docs/configuration_file.md index 2f574e207..992ff0906 100644 --- a/docs/configuration_file.md +++ b/docs/configuration_file.md @@ -152,12 +152,8 @@ This removes the specified view types from the dropdown menu and replaces them i ## Segments -Paint, rectangles, polygons and rulers share one registry of segments, configured under -`segments`. Each entry is keyed by name, and every appearance field is optional: an -omitted one means the app default for a new segment. For an existing session segment, -omitted fields keep the appearance it had before configuration. Replacing a config entry -removes its previous appearance overrides, including color, while keeping the segment id, -visibility and lock state. +Use `segments` to define segments by name. Paint, rectangles, polygons and rulers on +an image share its segments. ```json { @@ -168,40 +164,35 @@ visibility and lock state. } ``` -Fields: +- `color`: a 3, 4, 6 or 8 digit hex value (4 and 8 include alpha), or a CSS color + keyword. Invalid colors are reported and ignored. +- `fillOpacity` and `outlineOpacity`: mask fill and outline opacity, from 0 to 1. + The Display sliders in "Annotations" scale these per image. +- `strokeWidth`: line width for rectangles, polygons and rulers. -- `color` takes a hex value of 3, 4, 6 or 8 digits such as `#ff0000` (the 4 and 8 digit - forms carry alpha) or a CSS color keyword such as `green`. Any other value is reported - as an error and ignored. -- `fillOpacity` and `outlineOpacity`, from 0 to 1, set how strongly the segment's masks - are filled and outlined. The Display sliders in "Annotations" scale them per image. -- `strokeWidth` sets the line width of the segment's rectangles, polygons and rulers. +All fields are optional. Omitted fields use the app defaults. -Omitting the key leaves the registry alone. An empty record (`{}`) or `null` drops the -config's entries: a segment the config created is deleted unless a mask or shape -references it, and any other segment stays with its last configured appearance. +A configuration loaded with images applies to those images. A configuration loaded on +its own applies to the image that was active when loading started. Either way, the +configured segments also become the starting set for new images with no earlier image +to copy from. -Entries are keyed by name. A configured segment keeps its id while its key stays the -same, so recoloring it never detaches its masks and shapes. Changing a key moves the -entry to the segment of the new name, adding one if none exists, and drops the old entry -as above. Applying a config again restores each key's name and configured appearance, -undoing a rename made in the app. - -Configured segments outlive the images. Removing the last image deletes every other -segment and keeps these. +Applying a configuration again restores each configured name and appearance. Omitting +`segments` changes nothing. `{}` or `null` removes the configured segments, except any +that hold masks or shapes. ### Legacy `labels` A legacy `labels` section is converted into `segments` at configuration ingestion, with a deprecation warning. Runtime configuration contains only `segments`. Its -`defaultLabels`, `rulerLabels`, `rectangleLabels` and `polygonLabels` all describe the one -registry now, so they read as `segments` entries. A name that appears in more than one +`defaultLabels`, `rulerLabels`, `rectangleLabels` and `polygonLabels` become `segments` +entries shared by the tools on each image. A name that appears in more than one becomes a single segment: the first record to declare it sets its appearance, reading `rulerLabels`, `rectangleLabels` and `polygonLabels` in that order and `defaultLabels` last. `defaultLabels` stood in for the tools that declared no record of their own, so it is read only when at least one of the three tool records is omitted. A section whose -records are all omitted or `null` leaves the registry alone. A rectangle label's -`fillColor` is dropped with a warning, since fill color is a property of the rectangle +records are all omitted or `null` leaves segment definitions unchanged. A rectangle +label's `fillColor` is dropped with a warning, since fill color is a property of the rectangle rather than of the segment. A config carrying both `segments` and `labels` has been converted already, so `segments` is read, `labels` is ignored, and the warning says so. diff --git a/docs/quick_start_guide.md b/docs/quick_start_guide.md index c4baa90de..6c5c17a72 100644 --- a/docs/quick_start_guide.md +++ b/docs/quick_start_guide.md @@ -35,7 +35,7 @@ The three main radiological controls are as following: - 2D Left mouse button: Window / Level, Pan, Zoom, or Crosshairs: Select these options to control the function of the left mouse button in the 2D windows. ![Window-Level, Pan, Zoom, Crosshairs](./assets/10-volview-wl-pan-zoom-notes.jpg) -- 2D Annotations: Paint, rectangles, polygons and rulers all draw into the segments listed in the "Annotations" tab. Segments are shared by every tool and image: select one in the list before drawing, and use the Paint controls below the list to set the brush size, erase, or set an intensity threshold. When the ruler tool is selected, the left mouse button places and adjusts ruler end-markers, and right clicking an end-marker displays a menu for deleting that ruler. The "Measurements" section of the "Annotations" tab lists the rectangles, polygons and rulers on the current image, with buttons to jump to their slice or delete them. See ["Toolbar controls"](toolbar.html#2d-annotations) for more. ![Paint and Ruler](./assets/11-volview-paint-notes.jpg) +- 2D Annotations: Paint, rectangles, polygons and rulers all draw into the current image's segments listed in the "Annotations" tab. Each image keeps its own segments and selection, shared by all four tools. Select a segment in the list before drawing, and use the Paint controls below the list to set the brush size, erase, or set an intensity threshold. When the ruler tool is selected, the left mouse button places and adjusts ruler end-markers, and right clicking an end-marker displays a menu for deleting that ruler. The "Measurements" section of the "Annotations" tab lists the rectangles, polygons and rulers on the current image, with buttons to jump to their slice or delete them. See ["Toolbar controls"](toolbar.html#2d-annotations) for more. ![Paint and Ruler](./assets/11-volview-paint-notes.jpg) - 3D Crop: Select this tool to adjust the extent of data shown in the 3D rendering. In the 3D window you can pick and move the corner, edge, and side markers to make adjustments. In the 2D windows, grab and move the edges of the bounding box overlaid on the data. ![Crop](./assets/13-volview-crop.jpg) diff --git a/docs/toolbar.md b/docs/toolbar.md index 5d9837135..a3b9faa19 100644 --- a/docs/toolbar.md +++ b/docs/toolbar.md @@ -14,19 +14,22 @@ Window / Level, Pan, Zoom, or Crosshairs: Select these options to control the fu ## 2D Annotations -The "Annotations" tab lists segments shared by paint, rectangles, polygons and rulers. +The "Annotations" tab lists the current image's segments, shared by paint, rectangles, +polygons and rulers. Select a segment in the list, use `q` and `w` to cycle through segments, or press `1` through `0` to select one of the first ten (each row shows its key). Use "New segment" to add one, and its color dot or edit button to change its name and appearance. Drag a row, or press Alt+Up or Alt+Down, to reorder segments and their keys. The selection -applies across all four tools and images. +applies across all four tools on that image. Each image keeps its own definitions, +order and selection. A new image copies the previous image's definitions once, +without copying its masks or shapes. Each segment row has Reveal, Edit, Lock, Hide and Delete buttons. Reveal jumps to the segment's mask or shapes on the current image and stays disabled when it has none there. A locked segment keeps its voxels when another segment paints over it, and cannot be edited or deleted until it is unlocked. Delete removes the segment with its -masks and shapes from every image. The buttons beside the "Segments" heading lock or -hide every segment at once and save the current image's segments. Saving writes one +masks and shapes from the current image. The buttons beside the "Segments" heading +lock or hide every segment on that image at once and save its segments. Saving writes one file, or several bundled into a zip when segments overlap or number more than 65535. The "Display" section sets Fill Opacity, Outline Opacity and Outline Thickness for the @@ -95,8 +98,9 @@ New rulers use the selected segment from "Annotations". ### Segment configuration -If VolView loads a JSON file matching the schema below, segments are added to the -registry. Paint, rectangles, polygons and rulers all share `segments`. Appearance +If VolView loads a JSON file matching the schema below, it configures segment +definitions for the destination images and defaults for future images. Paint, +rectangles, polygons and rulers share `segments` on each image. Appearance fields are optional. See [segment configuration](./configuration_file.md#segments) for replacement behavior and how omitted fields use session appearance or defaults. diff --git a/src/actions/__tests__/loadImageSegmentSets.spec.ts b/src/actions/__tests__/loadImageSegmentSets.spec.ts new file mode 100644 index 000000000..59b390614 --- /dev/null +++ b/src/actions/__tests__/loadImageSegmentSets.spec.ts @@ -0,0 +1,492 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import JSZip from 'jszip'; +import vtkXMLImageDataReader from '@kitware/vtk.js/IO/XML/XMLImageDataReader'; +import vtkSTLReader from '@kitware/vtk.js/IO/Geometry/STLReader'; +import { z } from 'zod'; + +import { loadDataSources, loadFiles } from '@/src/actions/loadUserFiles'; +import { FILE_READERS } from '@/src/io'; +import { FILE_EXT_TO_MIME } from '@/src/io/mimeTypes'; +import { readFile } from '@/src/io/vtk/common'; +import { fileToDataSource } from '@/src/io/import/dataSource'; +import { registerConfigSection } from '@/src/io/import/configJson'; +import { buildSegNrrdMetadata } from '@/src/io/segNrrdMetadata'; +import { useDatasetStore } from '@/src/store/datasets'; +import { useImageCacheStore } from '@/src/store/image-cache'; +import { useImageStore } from '@/src/store/datasets-images'; +import { useViewStore } from '@/src/store/views'; +import { useSegmentStore } from '@/src/segmentation/segments'; +import { useSegmentationStore } from '@/src/segmentation/store'; +import useLoadDataStore from '@/src/store/load-data'; +import SegmentAssignmentList from '@/src/segmentation/components/SegmentAssignmentList.vue'; +import { + activateAppPinia, + markedVoxels, + serializeAnnotations, + strokeAt, +} from '@/src/segmentation/__tests__/segmentMaskFixtures'; +import { defer } from '@/src/utils'; + +enableAutoUnmount(afterEach); + +const imageFile = (name: string, mask = false) => + new File( + [ + ` + + + +${Array.from({ length: 32 }, (_, i) => (mask ? Number(i === 5) : i)).join(' ')} +`, + ], + name + ); + +const configFile = (name: string, extra = {}) => + new File( + [JSON.stringify({ segments: { [name]: { color: '#ff0000' } }, ...extra })], + 'config.json' + ); +const gatedConfig = (name: string) => { + const file = configFile(name); + const readText = file.text.bind(file); + const entered = defer(); + const gate = defer(); + vi.spyOn(file, 'text').mockImplementation(async () => { + entered.resolve(); + await gate.promise; + return readText(); + }); + return { file, entered: entered.promise, release: () => gate.resolve() }; +}; +const badImage = () => new File(['not VTI'], 'broken.vti'); +const vtiMime = FILE_EXT_TO_MIME.vti; +const stlMime = FILE_EXT_TO_MIME.stl; +const originalReaders = new Map([ + [vtiMime, FILE_READERS.get(vtiMime)!], + [stlMime, FILE_READERS.get(stlMime)!], +]); +const conversionListeners: Array<() => void> = []; +const nextLabelmapConversion = () => { + const completed = defer(); + conversionListeners.push( + useSegmentationStore().$onAction(({ name, after, onError }) => { + if (name !== 'startLabelmapConversion') return; + after(() => completed.resolve()); + onError((error) => completed.reject(error)); + }) + ); + void completed.promise.catch(() => {}); + return completed.promise; +}; + +let slowEntered = defer(); +let slowGate = defer(); + +beforeEach(() => { + activateAppPinia(); + slowEntered = defer(); + slowGate = defer(); + // Run the production codecs in process; only their Worker transport differs. + FILE_READERS.set(vtiMime, async (file) => { + if (file.name === 'pending.vti') { + slowEntered.resolve(); + await slowGate.promise; + } + const dataObject = await readFile(file, vtkXMLImageDataReader); + const headerMetadata = file.name.endsWith('.seg.vti') + ? buildSegNrrdMetadata( + [{ value: 1, name: 'Tumor', color: [0, 0, 255, 255], visible: true }], + [4, 4, 2] + ) + : undefined; + return { dataObject, headerMetadata }; + }); + FILE_READERS.set(stlMime, async (file) => ({ + dataObject: await readFile(file, vtkSTLReader, false), + })); +}); + +afterEach(() => { + conversionListeners.splice(0).forEach((unsubscribe) => unsubscribe()); + originalReaders.forEach((reader, mime) => FILE_READERS.set(mime, reader)); + vi.restoreAllMocks(); +}); + +const openImage = async (name: string) => { + const ids = await loadFiles([imageFile(name)]); + expect(ids).toHaveLength(1); + return ids![0]; +}; +const picker = (imageId: string) => { + const wrapper = mount(SegmentAssignmentList, { + props: { imageId }, + global: { + stubs: { + VList: { template: '
' }, + VListItem: { template: '
  • ' }, + VListItemTitle: { template: '' }, + }, + }, + }); + return () => wrapper.findAll('.title').map((title) => title.text()); +}; +const paintedName = (imageId: string) => { + strokeAt(imageId, [1, 1, 0]); + const store = useSegmentationStore(); + const [maskId] = store.getSegmentationForImage(imageId)!.order; + expect(markedVoxels(maskId)!.length).toBeGreaterThan(0); + return useSegmentStore().segments.appearanceOf( + store.getMask(maskId).segmentId + ).name; +}; +const duringPendingImage = async (check: () => Promise) => { + const pending = loadFiles([imageFile('pending.vti')]); + await slowEntered.promise; + try { + await check(); + } finally { + slowGate.resolve(); + await pending; + } +}; +const loadContainedFiles = async ( + container: 'files' | 'collection' | 'archive', + files: File[] +) => { + if (container === 'collection') + return loadDataSources([ + { type: 'collection', sources: files.map(fileToDataSource) }, + ]); + if (container === 'archive') { + const zip = new JSZip(); + for (const file of files) zip.file(file.name, await file.text()); + return loadFiles([ + new File([await zip.generateAsync({ type: 'arraybuffer' })], 'batch.zip'), + ]); + } + return loadFiles(files); +}; + +describe('image segment initialization through public loading', () => { + it.each(['configured', 'borrowed'] as const)( + 'keeps %s definitions usable while an unrelated image is unfinished', + async (source) => { + if (source === 'configured') await loadFiles([configFile('Liver')]); + else { + const first = await openImage('first.vti'); + useSegmentStore().segments.addSegment(first, { name: 'Liver' }); + } + await duringPendingImage(async () => { + const completed = await openImage('completed.vti'); + expect(useLoadDataStore().isLoading).toBe(true); + useViewStore().setDataForAllViews(completed); + await nextTick(); + expect(picker(completed)()).toEqual(['Liver']); + expect(paintedName(completed)).toBe('Liver'); + }); + } + ); + + it('seeds configured defaults with no other operation pending', async () => { + await loadFiles([configFile('Liver')]); + const completed = await openImage('completed.vti'); + expect(picker(completed)()).toEqual(['Liver']); + expect(paintedName(completed)).toBe('Liver'); + }); + + it('uses its accompanying segmentation before borrowed defaults', async () => { + await loadFiles([configFile('Liver')]); + await duringPendingImage(async () => { + const converted = nextLabelmapConversion(); + const ids = await loadFiles([ + imageFile('scan.vti'), + imageFile('scan.seg.vti', true), + ]); + const views = useViewStore(); + const imageId = views.getView(views.activeView)!.dataID!; + expect(ids).toContain(imageId); + const names = picker(imageId); + await converted; + await flushPromises(); + expect(names()).toEqual(['Tumor']); + expect(paintedName(imageId)).toBe('Tumor'); + }); + }); + + it('applies a companion config to an image shown before the config is read', async () => { + await loadFiles([configFile('Liver')]); + const { + file: config, + entered: configEntered, + release: releaseConfig, + } = gatedConfig('Tumor'); + const published = defer(); + const unsubscribe = useImageStore().$onAction(({ name, args, after }) => { + if (name === 'addVTKImageData' && args[0] === 'owned.vti') + after((id) => published.resolve(id)); + }); + const unrelated = loadFiles([imageFile('pending.vti')]); + await slowEntered.promise; + const own = loadFiles([imageFile('owned.vti'), config]); + try { + const imageId = await published.promise; + await configEntered; + useViewStore().setDataForAllViews(imageId); + await nextTick(); + const names = picker(imageId); + expect(names()).toEqual(['Liver']); + slowGate.resolve(); + await unrelated; + await nextTick(); + expect(names()).toEqual(['Liver']); + releaseConfig(); + await own; + await nextTick(); + expect(names()).toEqual(['Tumor']); + expect(paintedName(imageId)).toBe('Tumor'); + } finally { + unsubscribe(); + releaseConfig(); + slowGate.resolve(); + await Promise.all([own, unrelated]); + } + }); +}); + +describe('standalone config destination during asynchronous loading', () => { + it.each([ + 'focus changed', + 'no initial image', + 'initial image removed', + ] as const)( + 'keeps the destination captured when loading began: %s', + async (transition) => { + const initial = + transition === 'no initial image' + ? undefined + : (await loadFiles([ + imageFile('initial.vti'), + configFile('Liver'), + ]))![0]; + let unrelated: string | undefined; + if (transition === 'focus changed') { + [unrelated] = (await loadFiles([ + imageFile('unrelated.vti'), + configFile('Kidney'), + ]))!; + useViewStore().setDataForAllViews(initial!); + } + + const { + file: config, + entered: configEntered, + release: releaseConfig, + } = gatedConfig('Tumor'); + const loading = loadFiles([config]); + try { + await configEntered; + if (transition === 'initial image removed') + useDatasetStore().remove(initial!); + if (transition !== 'focus changed') + unrelated = await openImage('unrelated.vti'); + useViewStore().setDataForAllViews(unrelated!); + await nextTick(); + const names = picker(unrelated!); + const expected = + transition === 'focus changed' + ? ['Kidney'] + : transition === 'initial image removed' + ? ['Liver'] + : []; + expect(names()).toEqual(expected); + + releaseConfig(); + await loading; + await nextTick(); + expect(names()).toEqual(expected); + if (transition === 'focus changed') + expect(picker(initial!)()).toEqual(['Tumor']); + if (transition === 'initial image removed') + expect( + serializeAnnotations().imageSegmentSets?.find( + ({ imageId }) => imageId === initial + ) + ).toBeUndefined(); + expect( + serializeAnnotations().seedSegmentDefaults?.map(({ name }) => name) + ).toEqual(['Tumor']); + } finally { + releaseConfig(); + await loading; + } + } + ); + + it.each( + (['files', 'collection', 'archive'] as const).flatMap((container) => [ + { + container, + failure: 'recognized config validation', + contents: JSON.stringify({ segments: 17 }), + expected: ['Tumor'], + }, + { + container, + failure: 'unrecognized JSON syntax', + contents: '{', + expected: ['Liver'], + }, + ]) + )( + 'keeps standalone config scope with a $failure failure through $container', + async ({ container, contents, expected }) => { + const [active] = (await loadFiles([ + imageFile('active.vti'), + configFile('Liver'), + ]))!; + const setError = vi.spyOn(useLoadDataStore(), 'setError'); + const ids = await loadContainedFiles(container, [ + configFile('Tumor'), + new File([contents], 'invalid-config.json'), + ]); + await nextTick(); + expect(ids).toEqual([]); + expect(picker(active)()).toEqual(expected); + expect( + serializeAnnotations().seedSegmentDefaults?.map(({ name }) => name) + ).toEqual(['Tumor']); + expect(setError).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + message: expect.stringContaining('invalid-config.json'), + }) + ); + } + ); +}); + +describe('background labelmap conversion', () => { + it('finishes the file load while its segmentation is still streaming', async () => { + await loadFiles([configFile('Liver')]); + const cache = useImageCacheStore(); + let streamingId: string | undefined; + const unsubscribe = cache.$onAction(({ name, args, after }) => { + if (name === 'addVTKImageData' && args[1] === 'stream.seg.vti') + after((id) => { + streamingId = id; + cache.imageById[id].status.value = 'incomplete'; + }); + }); + try { + const converted = nextLabelmapConversion(); + const ids = await loadFiles([ + imageFile('stream.vti'), + imageFile('stream.seg.vti', true), + ]); + expect(ids).toHaveLength(2); + expect(useLoadDataStore().isLoading).toBe(false); + const views = useViewStore(); + const imageId = views.getView(views.activeView)!.dataID!; + const names = picker(imageId); + expect(names()).toEqual([]); + cache.imageById[streamingId!].status.value = 'complete'; + await converted; + await flushPromises(); + expect(names()).toEqual(['Tumor']); + expect(paintedName(imageId)).toBe('Tumor'); + } finally { + unsubscribe(); + if (streamingId) cache.imageById[streamingId].status.value = 'complete'; + } + }); +}); + +describe('configuration archives', () => { + it('ignores Finder metadata when applying a standalone configuration archive', async () => { + const [imageId] = (await loadFiles([ + imageFile('active.vti'), + configFile('Liver'), + ]))!; + const zip = new JSZip(); + zip.file('config.json', await configFile('Tumor').text()); + zip.file('__MACOSX/._config.json', 'AppleDouble metadata'); + const reportError = vi.spyOn(useLoadDataStore(), 'setError'); + const ids = await loadFiles([ + new File( + [await zip.generateAsync({ type: 'arraybuffer' })], + 'config.zip' + ), + ]); + expect(ids).toEqual([]); + expect(picker(imageId)()).toEqual(['Tumor']); + expect(reportError).not.toHaveBeenCalled(); + }); +}); + +describe('destination scope when companion data imports fail', () => { + it.each(['files', 'collection', 'archive'] as const)( + 'does not apply the companion config to the active image through %s', + async (container) => { + const [active] = (await loadFiles([ + imageFile('active.vti'), + configFile('Liver'), + ]))!; + const ids = await loadContainedFiles(container, [ + badImage(), + configFile('Tumor'), + ]); + await nextTick(); + expect(ids).toEqual([]); + expect(picker(active)()).toEqual(['Liver']); + expect( + serializeAnnotations().seedSegmentDefaults?.map(({ name }) => name) + ).toEqual(['Tumor']); + } + ); + + it('keeps model-only companion config off the active image', async () => { + const [active] = (await loadFiles([ + imageFile('active.vti'), + configFile('Liver'), + ]))!; + const model = new File( + [ + `solid triangle +facet normal 0 0 1 +outer loop +vertex 0 0 0 +vertex 1 0 0 +vertex 0 1 0 +endloop +endfacet +endsolid triangle`, + ], + 'triangle.stl' + ); + await loadFiles([model, configFile('Tumor')]); + expect(picker(active)()).toEqual(['Liver']); + }); + + it.each([false, true])( + 'still applies standalone config with a pre-state failure of %s', + async (failPreState) => { + const [active] = (await loadFiles([ + imageFile('active.vti'), + configFile('Liver'), + ]))!; + registerConfigSection({ + key: 'loadingBoundaryFailure', + schema: z.boolean().optional(), + apply: (value) => { + if (value) throw new Error('Unavailable config section'); + }, + }); + await loadFiles([ + configFile('Tumor', { loadingBoundaryFailure: failPreState }), + ]); + expect(picker(active)()).toEqual(['Tumor']); + } + ); +}); diff --git a/src/actions/__tests__/operatingSystemMetadataImports.spec.ts b/src/actions/__tests__/operatingSystemMetadataImports.spec.ts new file mode 100755 index 000000000..70acf0044 --- /dev/null +++ b/src/actions/__tests__/operatingSystemMetadataImports.spec.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { enableAutoUnmount, mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import JSZip from 'jszip'; + +import { loadDataSources, loadFiles } from '@/src/actions/loadUserFiles'; +import { config, applyPostStateConfig } from '@/src/io/import/configJson'; +import { fileToDataSource, uriToDataSource } from '@/src/io/import/dataSource'; +import SegmentAssignmentList from '@/src/segmentation/components/SegmentAssignmentList.vue'; +import { + activateAppPinia, + viewImage, +} from '@/src/segmentation/__tests__/segmentMaskFixtures'; + +enableAutoUnmount(afterEach); +beforeEach(() => activateAppPinia()); + +const configFile = () => + new File([JSON.stringify({ segments: { Tumor: {} } })], 'config.json'); + +async function viewedSegments() { + await viewImage('active'); + applyPostStateConfig(config.parse({ segments: { Liver: {} } }), undefined, [ + 'active', + ]); + const picker = mount(SegmentAssignmentList, { + props: { imageId: 'active' }, + global: { + stubs: { + VList: { template: '
    ' }, + VListItem: { template: '
  • ' }, + VListItemTitle: { template: '' }, + }, + }, + }); + return () => picker.findAll('.name').map((row) => row.text()); +} + +async function importWithSibling( + container: 'files' | 'collection' | 'archive' | 'uri', + name: string +) { + const sibling = new File(['Finder metadata'], name); + if (container === 'archive') { + const zip = new JSZip(); + zip.file('config.json', await configFile().text()); + zip.file(name, await sibling.text()); + return loadFiles([ + new File( + [await zip.generateAsync({ type: 'arraybuffer' })], + 'config.zip' + ), + ]); + } + if (container === 'uri') + return loadDataSources([ + fileToDataSource(configFile()), + uriToDataSource('data:text/plain,Finder%20metadata', name), + ]); + const sources = [configFile(), sibling].map(fileToDataSource); + return container === 'collection' + ? loadDataSources([{ type: 'collection', sources }]) + : loadDataSources(sources); +} + +describe('operating-system metadata beside a configuration', () => { + it.each(['files', 'collection', 'archive', 'uri'] as const)( + 'applies a standalone config to the viewed image through %s', + async (container) => { + const names = await viewedSegments(); + await importWithSibling(container, '._config.json'); + await nextTick(); + expect(names()).toEqual(['Tumor']); + } + ); + + it.each(['.DS_Store', '__MACOSX/ordinary-file'])( + 'ignores the metadata path %s in a dropped folder', + async (relativePath) => { + const names = await viewedSegments(); + const sibling = new File( + ['Finder metadata'], + relativePath.split('/').at(-1)! + ); + Object.defineProperty(sibling, 'webkitRelativePath', { + value: `folder/${relativePath}`, + }); + await loadFiles([configFile(), sibling]); + await nextTick(); + expect(names()).toEqual(['Tumor']); + } + ); + + it('still treats an ordinary unsupported sibling as data intent', async () => { + const names = await viewedSegments(); + await importWithSibling('files', 'README.md'); + await nextTick(); + expect(names()).toEqual(['Liver']); + }); + + it('honors an explicit manifest reference even when its name looks hidden', async () => { + const names = await viewedSegments(); + await loadDataSources([ + fileToDataSource(configFile()), + { + ...fileToDataSource(new File(['unreadable data'], '._volume.vti')), + stateFileLeaf: { stateID: 'saved-volume' }, + }, + ]); + await nextTick(); + expect(names()).toEqual(['Liver']); + }); +}); diff --git a/src/actions/loadUserFiles.ts b/src/actions/loadUserFiles.ts index f0fa45f68..81882f377 100644 --- a/src/actions/loadUserFiles.ts +++ b/src/actions/loadUserFiles.ts @@ -9,7 +9,8 @@ import useLoadDataStore from '@/src/store/load-data'; import { useDICOMStore } from '@/src/store/datasets-dicom'; import { useLayersStore } from '@/src/store/datasets-layers'; import { useSegmentationStore } from '@/src/segmentation/store'; -import { wrapInArray, nonNullable, partition } from '@/src/utils'; +import { useSegmentStore } from '@/src/segmentation/segments'; +import { wrapInArray, nonNullable, partition, ensureError } from '@/src/utils'; import { basename } from '@/src/utils/path'; import { parseUrl } from '@/src/utils/url'; import { logError } from '@/src/utils/loggers'; @@ -29,6 +30,7 @@ import { } from '@/src/io/import/common'; import { isDicomImage } from '@/src/utils/dataSelection'; import { useViewStore } from '@/src/store/views'; +import { useMessageStore } from '@/src/store/messages'; // higher value priority is preferred for picking a primary selection const BASE_MODALITY_TYPES = { @@ -235,7 +237,7 @@ function autoLayerByName( // Converts other DataSources into segments on the primary image: // - DICOM SEG modalities with matching StudyUIDs. // - DataSources that have a name like foo.segmentation.bar and the primary DataSource is named foo.baz -function loadSegmentations( +async function loadSegmentations( primaryDataSource: LoadableVolumeResult, succeeded: Array, segmentationExtension: string @@ -261,13 +263,14 @@ function loadSegmentations( }); const segmentationStore = useSegmentationStore(); - [...otherSegVolumesInStudy, ...matchingNames].forEach((ds) => { - const loadable = toDataSelection(ds); - segmentationStore.startLabelmapConversion( - loadable, - toDataSelection(primaryDataSource) - ); - }); + await Promise.all( + [...otherSegVolumesInStudy, ...matchingNames].map((ds) => + segmentationStore.startLabelmapConversion( + toDataSelection(ds), + toDataSelection(primaryDataSource) + ) + ) + ); } export type DataSourceImporter = ( @@ -276,7 +279,6 @@ export type DataSourceImporter = ( export function showImportedData(results: ImportResult[]) { const loadDataStore = useLoadDataStore(); - const primaryDataSource = findBaseDataSource( results, loadDataStore.segmentationExtension, @@ -284,14 +286,30 @@ export function showImportedData(results: ImportResult[]) { ); if (!primaryDataSource || !isVolumeResult(primaryDataSource)) return; - useViewStore().setDataForAllViews(toDataSelection(primaryDataSource)); - autoLayerDicoms(primaryDataSource, results); - autoLayerByName(primaryDataSource, results, loadDataStore.layerExtension); - loadSegmentations( - primaryDataSource, - results, - loadDataStore.segmentationExtension - ); + const primarySelection = toDataSelection(primaryDataSource); + const releaseInitialization = useSegmentStore().deferInitialization([ + primarySelection, + ]); + try { + useViewStore().setDataForAllViews(primarySelection); + autoLayerDicoms(primaryDataSource, results); + autoLayerByName(primaryDataSource, results, loadDataStore.layerExtension); + const completed = loadSegmentations( + primaryDataSource, + results, + loadDataStore.segmentationExtension + ) + .catch((error) => + useMessageStore().addError('Failed to import segmentations', { + error: ensureError(error), + }) + ) + .finally(releaseInitialization); + return { completed }; + } catch (error) { + releaseInitialization(); + throw error; + } } type LoadDataSourcesOutcome = { @@ -358,21 +376,8 @@ function loadDataSourcesWithOutcome( }; }; - const wrapWithLoading = ( - fn: (...args: Args) => Promise - ) => { - const { startLoading, stopLoading } = useLoadDataStore(); - return async function wrapper(...args: Args): Promise { - try { - startLoading(); - return await fn(...args); - } finally { - stopLoading(); - } - }; - }; - - return wrapWithLoading(load)(); + loadDataStore.startLoading(); + return load().finally(() => loadDataStore.stopLoading()); } export function loadDataSources( diff --git a/src/components/ControlsStripTools.vue b/src/components/ControlsStripTools.vue index 2923ea16a..71449bbf4 100644 --- a/src/components/ControlsStripTools.vue +++ b/src/components/ControlsStripTools.vue @@ -80,7 +80,7 @@ import ControlButton from '@/src/components/ControlButton.vue'; import ReasonedAction from '@/src/components/ReasonedAction.vue'; import ItemGroup from '@/src/components/ItemGroup.vue'; import GroupableItem from '@/src/components/GroupableItem.vue'; -import { useToolStore, getToolUnavailableReason } from '@/src/store/tools'; +import { useToolStore } from '@/src/store/tools'; import { useEffectiveView } from '@/src/composables/useEffectiveView'; import { toRef } from 'vue'; import MenuControlButton from '@/src/components/MenuControlButton.vue'; @@ -162,8 +162,7 @@ export default defineComponent({ const toolUnavailableReason = (tool: Tools) => { if (tool === Tools.Paint) return toolStore.paintUnavailableReason || undefined; - const effective = activeEffective.value; - const sharedReason = getToolUnavailableReason(tool, effective); + const sharedReason = toolStore.toolUnavailableReason(tool); if (sharedReason) return sharedReason; if (tool === Tools.WindowLevel && windowingUnavailableReason.value) return windowingUnavailableReason.value; diff --git a/src/components/EditableItemList.vue b/src/components/EditableItemList.vue index 60fb9bdfd..a578de825 100644 --- a/src/components/EditableItemList.vue +++ b/src/components/EditableItemList.vue @@ -3,6 +3,7 @@ import { nextTick, ref, watch } from 'vue'; import { Maybe } from '@/src/types'; +import ReasonedAction from '@/src/components/ReasonedAction.vue'; const emit = defineEmits<{ create: []; @@ -13,6 +14,7 @@ const emit = defineEmits<{ const props = defineProps<{ items: Array; createText: string; + createReason?: string; reorderHint: string; modelValue: Maybe; selectionRevision?: number; @@ -171,18 +173,21 @@ const moveBy = async (id: string, offset: number, event: KeyboardEvent) => {
    - -
    - mdi-plus - {{ createText }} -
    -
    + + +
    @@ -205,7 +210,7 @@ const moveBy = async (id: string, offset: number, event: KeyboardEvent) => { } .v-list-item.item-row, -.v-list-item.create-row { +.v-list-item.create-row-content { border: 1px solid transparent; border-radius: 4px; /* The default inset would dwarf the gaps between the row's parts. */ @@ -231,6 +236,18 @@ const moveBy = async (id: string, offset: number, event: KeyboardEvent) => { .create-row { opacity: var(--v-medium-emphasis-opacity); + text-align: left; + border-radius: 4px; +} + +.create-row:hover:enabled, +.create-row:focus-visible { + background-color: rgba(var(--v-theme-on-surface), var(--v-hover-opacity)); +} + +.create-row:disabled { + opacity: var(--v-disabled-opacity); + pointer-events: none; } .create-icon { diff --git a/src/components/ImageDataBrowser.vue b/src/components/ImageDataBrowser.vue index dc9ded5c1..fb49c46a1 100644 --- a/src/components/ImageDataBrowser.vue +++ b/src/components/ImageDataBrowser.vue @@ -7,6 +7,7 @@ import ReasonedAction from '@/src/components/ReasonedAction.vue'; import SegmentationConversionProgress from '@/src/components/SegmentationConversionProgress.vue'; import { createVTKImageThumbnailer } from '@/src/core/thumbnailers/vtk-image'; import { useSegmentationStore } from '@/src/segmentation/store'; +import { useSegmentStore } from '@/src/segmentation/segments'; import { isRegularImage, type DataSelection, @@ -167,6 +168,7 @@ export default defineComponent({ function convertToLabelMap(key: string) { if (currentImageID.value) { + useSegmentStore().prepareForEdit(currentImageID.value); segmentationStore.startLabelmapConversion(key, currentImageID.value); } } diff --git a/src/components/LayoutGridItem.vue b/src/components/LayoutGridItem.vue index f8adb33c4..87e0e8d5f 100644 --- a/src/components/LayoutGridItem.vue +++ b/src/components/LayoutGridItem.vue @@ -54,8 +54,8 @@ function onDrop(event: DragEvent) { const droppedImageID = event.dataTransfer?.getData(IMAGE_DRAG_MEDIA_TYPE); if (droppedImageID) { - viewStore.setActiveView(props.viewId); viewStore.setDataForView(props.viewId, droppedImageID); + viewStore.setActiveView(props.viewId); } } diff --git a/src/components/MeasurementsToolList.vue b/src/components/MeasurementsToolList.vue index f27a2f23e..b5c5980ed 100644 --- a/src/components/MeasurementsToolList.vue +++ b/src/components/MeasurementsToolList.vue @@ -169,6 +169,7 @@ function toggleSelectedHidden() { diff --git a/src/components/__tests__/EditableItemList.spec.ts b/src/components/__tests__/EditableItemList.spec.ts new file mode 100644 index 000000000..50c85c444 --- /dev/null +++ b/src/components/__tests__/EditableItemList.spec.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { enableAutoUnmount, mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { createVuetify } from 'vuetify'; + +import EditableItemList from '@/src/components/EditableItemList.vue'; + +enableAutoUnmount(afterEach); + +describe('creating an item', () => { + it('uses a disabled native button until the blocking reason clears', async () => { + const wrapper = mount(EditableItemList, { + props: { + items: [], + modelValue: undefined, + createText: 'New segment', + createReason: 'Select an image to edit segments', + reorderHint: 'Drag to reorder', + }, + global: { plugins: [createVuetify()] }, + }); + const button = wrapper.get('.create-row'); + + expect(button.element.tagName).toBe('BUTTON'); + expect(button.element.disabled).toBe(true); + button.element.click(); + button.element.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await nextTick(); + expect(wrapper.emitted('create')).toBeUndefined(); + + await wrapper.setProps({ createReason: '' }); + expect(button.element.disabled).toBe(false); + button.element.click(); + expect(wrapper.emitted('create')).toEqual([[]]); + }); +}); diff --git a/src/components/__tests__/MeasurementsToolList.spec.ts b/src/components/__tests__/MeasurementsToolList.spec.ts index 3e71b3309..d85e5ff4b 100644 --- a/src/components/__tests__/MeasurementsToolList.spec.ts +++ b/src/components/__tests__/MeasurementsToolList.spec.ts @@ -86,7 +86,7 @@ describe('measurement list', () => { it('shows a shape of a hidden segment as hidden and says why its eye is off', () => { const { segments } = useSegmentStore(); - const segmentId = segments.mintSegment({ name: 'Tumor' }); + const segmentId = segments.mintSegment('img-1', { name: 'Tumor' }); drawRuler(segmentId); segments.updateSegment(segmentId, { visible: false }); @@ -100,7 +100,7 @@ describe('measurement list', () => { }); it('names the shape of an unnamed segment', () => { - drawRuler(useSegmentStore().segments.mintSegment({ name: '' })); + drawRuler(useSegmentStore().segments.mintSegment('img-1', { name: '' })); expect(mountList().get('.row-title').text()).toBe('(no name)'); }); @@ -123,7 +123,9 @@ describe('measurement list', () => { }); it('says why the selection actions wait for a selection', () => { - drawRuler(useSegmentStore().segments.mintSegment({ name: 'Tumor' })); + drawRuler( + useSegmentStore().segments.mintSegment('img-1', { name: 'Tumor' }) + ); const wrapper = mountList(); const hide = actionLabelled(wrapper, /^Hide selected/); @@ -141,8 +143,8 @@ describe('measurement list', () => { it('selects all rows from a partial selection and clears them on a second click', async () => { const { segments } = useSegmentStore(); - drawRuler(segments.mintSegment({ name: 'Tumor' })); - drawRuler(segments.mintSegment({ name: 'Node' })); + drawRuler(segments.mintSegment('img-1', { name: 'Tumor' })); + drawRuler(segments.mintSegment('img-1', { name: 'Node' })); const wrapper = mountList(); const selectAll = wrapper.get( 'input[aria-label="Select all measurements"]' @@ -185,9 +187,12 @@ describe('measurement list', () => { it('hides and shows selected measurements without changing hidden-segment children or unselected rows', async () => { const { segments } = useSegmentStore(); - const tumor = segments.mintSegment({ name: 'Tumor' }); - const node = segments.mintSegment({ name: 'Node', visible: false }); - const other = segments.mintSegment({ name: 'Other' }); + const tumor = segments.mintSegment('img-1', { name: 'Tumor' }); + const node = segments.mintSegment('img-1', { + name: 'Node', + visible: false, + }); + const other = segments.mintSegment('img-1', { name: 'Other' }); drawRuler(tumor); drawRuler(tumor, { slice: 1, hidden: true }); drawRuler(node, { slice: 2 }); @@ -261,7 +266,10 @@ describe('measurement list', () => { it('explains why a selection under hidden segments cannot be shown or hidden', async () => { drawRuler( - useSegmentStore().segments.mintSegment({ name: 'Tumor', visible: false }) + useSegmentStore().segments.mintSegment('img-1', { + name: 'Tumor', + visible: false, + }) ); const wrapper = mountList(); await wrapper @@ -283,11 +291,11 @@ describe('measurement list', () => { it('deletes selected rulers and rectangles while keeping an unselected measurement', async () => { const { segments } = useSegmentStore(); - drawRuler(segments.mintSegment({ name: 'Tumor' })); - drawRuler(segments.mintSegment({ name: 'Other' })); + drawRuler(segments.mintSegment('img-1', { name: 'Tumor' })); + drawRuler(segments.mintSegment('img-1', { name: 'Other' })); useRectangleStore().addTool({ imageID: IMAGE_ID, - segmentId: segments.mintSegment({ name: 'Node' }), + segmentId: segments.mintSegment('img-1', { name: 'Node' }), slice: 0, frameOfReference: AXIAL_FRAME_OF_REFERENCE, }); @@ -318,11 +326,14 @@ describe('measurement list', () => { }); it('shows a cine ruler with a one-based frame caption and its measured length', () => { - drawRuler(useSegmentStore().segments.mintSegment({ name: 'Tumor' }), { - frame: 7, - firstPoint: [0, 0, 0], - secondPoint: [3, 4, 0], - }); + drawRuler( + useSegmentStore().segments.mintSegment('img-1', { name: 'Tumor' }), + { + frame: 7, + firstPoint: [0, 0, 0], + secondPoint: [3, 4, 0], + } + ); const row = mountList().get('[data-testid="segment-shape-row"]'); expect(row.get('.row-subtitle').text()).toBe('Frame 8 5.00mm'); @@ -333,8 +344,8 @@ describe('measurement list', () => { it('reassigns only the measurement whose segment picker is used', async () => { const registry = useSegmentStore().segments; - const first = registry.mintSegment({ name: 'Tumor' }); - registry.mintSegment({ name: 'Node' }); + const first = registry.mintSegment('img-1', { name: 'Tumor' }); + registry.mintSegment('img-1', { name: 'Node' }); drawRuler(first); drawRuler(first); const wrapper = mountList(); diff --git a/src/components/tools/paint/PaintWidget2D.vue b/src/components/tools/paint/PaintWidget2D.vue index 8f5b1d0d3..77247be91 100644 --- a/src/components/tools/paint/PaintWidget2D.vue +++ b/src/components/tools/paint/PaintWidget2D.vue @@ -70,11 +70,18 @@ export default defineComponent({ // --- widget representation config --- // - const { segments } = useSegmentStore(); + const segmentStore = useSegmentStore(); + const { segments } = segmentStore; + const editingAvailable = computed( + () => !segmentStore.initializationReason(imageId.value) + ); + const pointerInView = ref(false); watchEffect(() => { - const [r, g, b] = segments.appearanceOf( - segments.selectedSegmentId.value - ).color; + if (!pointerInView.value) return; + const segmentId = segments.selectedSegmentId(imageId.value); + const [r, g, b] = segmentId + ? segments.appearanceOf(segmentId).color + : segments.nextSegmentColor(imageId.value); widgetState.getBrush().setColor3([r, g, b]); }); @@ -97,7 +104,7 @@ export default defineComponent({ // --- interaction --- // onVTKEvent(widget, 'onStartInteractionEvent', (event) => { - if (!imageId.value) return; + if (!imageId.value || !editingAvailable.value) return; const origin = widgetState.getBrush().getOrigin()!; if (event?.sampling) { paintStore.selectSegmentAt(vec3.clone(origin), imageId.value); @@ -113,7 +120,7 @@ export default defineComponent({ }); onVTKEvent(widget, 'onInteractionEvent', () => { - if (!imageId.value) return; + if (!imageId.value || !editingAvailable.value) return; const origin = widgetState.getBrush().getOrigin()!; paintStore.placeStrokePoint( vec3.clone(origin), @@ -151,7 +158,6 @@ export default defineComponent({ // --- visibility --- // let checkIfPointerInView = false; - const pointerInView = ref(false); watchEffect(() => { if (pointerInView.value && imageId.value) { paintStore.setSliceAxis(viewAxisIndex.value, imageId.value); @@ -160,7 +166,9 @@ export default defineComponent({ const cursorStyles = view.widgetManager.getCursorStyles(); watchEffect(() => { widget.setSampling(sampling.value); - widget.setVisibility(pointerInView.value && !sampling.value); + widget.setVisibility( + pointerInView.value && !sampling.value && editingAvailable.value + ); const cursor = sampling.value ? `url("${eyedropperCursor}") 2 22, crosshair` : cursorStyles.default; @@ -173,23 +181,26 @@ export default defineComponent({ }); // Turn on widget visibility and update stencil if mouse starts within view + const enterView = () => { + pointerInView.value = true; + }; const showPreviewOnFirstMove = () => { if (!checkIfPointerInView) return; checkIfPointerInView = false; - pointerInView.value = true; + enterView(); }; onVTKEvent(view.interactor, 'onMouseMove', showPreviewOnFirstMove); - onVTKEvent(view.interactor, 'onMouseEnter', () => { - pointerInView.value = true; - }); + onVTKEvent(view.interactor, 'onMouseEnter', enterView); onVTKEvent(view.interactor, 'onMouseLeave', () => { pointerInView.value = false; }); watchEffect(() => { - widget.setEnabled(paintStore.isPaintingModeActive); + widget.setEnabled( + paintStore.isPaintingModeActive && editingAvailable.value + ); }); // Brush size scroll wheel control with customizable modifier key diff --git a/src/components/tools/polygon/PolygonTool.vue b/src/components/tools/polygon/PolygonTool.vue index 6e4234003..a9ff2f2d5 100644 --- a/src/components/tools/polygon/PolygonTool.vue +++ b/src/components/tools/polygon/PolygonTool.vue @@ -50,6 +50,7 @@