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. 
-- 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. 
+- 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. 
- 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. 
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: '