From 1ab9df5f8fd9009603e65019a95b331054686d93 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 15 Sep 2026 15:18:43 -0400 Subject: [PATCH 01/39] refactor(processing): rename the segmentation result instruction --- backend-contract/README.md | 13 ++++++ .../fixtures/negative/wrong-length-color.json | 2 +- ... intent.import-segmentation.embedded.json} | 2 +- ...nt.import-segmentation.with-segments.json} | 2 +- .../generated/job-results.schema.json | 2 +- backend-contract/generated/openapi.json | 6 +-- .../generated/result-intent.schema.json | 2 +- backend-contract/package.json | 2 +- .../processing/__tests__/wire.spec.ts | 26 +++++------ backend-contract/processing/openapi.ts | 2 +- backend-contract/processing/wire.ts | 8 ++-- .../__tests__/applyResults.segments.spec.ts | 2 +- src/processing/__tests__/applyResults.spec.ts | 46 +++++++++---------- src/processing/applyResults.ts | 4 +- .../engine/__tests__/resultFiles.spec.ts | 2 +- .../engine/__tests__/resultToIntent.spec.ts | 2 +- src/processing/engine/__tests__/wire.spec.ts | 4 +- 17 files changed, 70 insertions(+), 57 deletions(-) rename backend-contract/fixtures/wire/{intent.add-segment-group.embedded.json => intent.import-segmentation.embedded.json} (88%) rename backend-contract/fixtures/wire/{intent.add-segment-group.with-segments.json => intent.import-segmentation.with-segments.json} (95%) diff --git a/backend-contract/README.md b/backend-contract/README.md index e6fc24fec..de7f7b7a2 100644 --- a/backend-contract/README.md +++ b/backend-contract/README.md @@ -113,6 +113,19 @@ Two versions on separate clocks: the task-spec `specVersion`. These version the wire vocabulary for additive compatibility negotiation. +### Result instruction rollout + +Contract artifact 0.3.0 uses intent vocabulary 3 and names segmentation import +`import-segmentation`. Deploy the updated producer and VolView client together. +An older client treats the unfamiliar instruction as an ordinary result and +will not apply its segmentation automatically. Update Girder's pinned VolView +package when releasing the paired change. + +This vocabulary change does not change task-spec versions or saved-session +schemas. Girder projects stored job outputs into current instructions when +results are requested; stored output references and mask provenance keep their +identities. + ## Regenerating ``` diff --git a/backend-contract/fixtures/negative/wrong-length-color.json b/backend-contract/fixtures/negative/wrong-length-color.json index f5aa4fee1..cc483faa6 100644 --- a/backend-contract/fixtures/negative/wrong-length-color.json +++ b/backend-contract/fixtures/negative/wrong-length-color.json @@ -1,6 +1,6 @@ { "id": "6600000000000000000000e1", - "intent": "add-segment-group", + "intent": "import-segmentation", "url": "/api/v1/file/6600000000000000000000e1/proxiable/otsu.nii.gz", "name": "otsu.nii.gz", "segments": [ diff --git a/backend-contract/fixtures/wire/intent.add-segment-group.embedded.json b/backend-contract/fixtures/wire/intent.import-segmentation.embedded.json similarity index 88% rename from backend-contract/fixtures/wire/intent.add-segment-group.embedded.json rename to backend-contract/fixtures/wire/intent.import-segmentation.embedded.json index 5c6f74ba9..a0a1da4c5 100644 --- a/backend-contract/fixtures/wire/intent.add-segment-group.embedded.json +++ b/backend-contract/fixtures/wire/intent.import-segmentation.embedded.json @@ -1,6 +1,6 @@ { "id": "6600000000000000000000e2", - "intent": "add-segment-group", + "intent": "import-segmentation", "url": "/api/v1/file/6600000000000000000000e2/proxiable/threshold.seg.nrrd", "name": "threshold.seg.nrrd", "source": { diff --git a/backend-contract/fixtures/wire/intent.add-segment-group.with-segments.json b/backend-contract/fixtures/wire/intent.import-segmentation.with-segments.json similarity index 95% rename from backend-contract/fixtures/wire/intent.add-segment-group.with-segments.json rename to backend-contract/fixtures/wire/intent.import-segmentation.with-segments.json index fa0f30949..87cb530d3 100644 --- a/backend-contract/fixtures/wire/intent.add-segment-group.with-segments.json +++ b/backend-contract/fixtures/wire/intent.import-segmentation.with-segments.json @@ -1,6 +1,6 @@ { "id": "6600000000000000000000e1", - "intent": "add-segment-group", + "intent": "import-segmentation", "url": "/api/v1/file/6600000000000000000000e1/proxiable/otsu.nii.gz", "name": "otsu.nii.gz", "segments": [ diff --git a/backend-contract/generated/job-results.schema.json b/backend-contract/generated/job-results.schema.json index 79b8f1c1f..7b651e668 100644 --- a/backend-contract/generated/job-results.schema.json +++ b/backend-contract/generated/job-results.schema.json @@ -114,7 +114,7 @@ "properties": { "intent": { "type": "string", - "const": "add-segment-group" + "const": "import-segmentation" }, "id": { "type": "string", diff --git a/backend-contract/generated/openapi.json b/backend-contract/generated/openapi.json index 71adfe668..24c1e00d1 100644 --- a/backend-contract/generated/openapi.json +++ b/backend-contract/generated/openapi.json @@ -3,8 +3,8 @@ "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema", "info": { "title": "VolView neutral backend contract", - "version": "0.2.0", - "description": "DRAFT 0.x — shapes may change until a second backend passes the conformance kit (the pinned 1.0 criterion). The neutral REST surface the VolView client calls to run processing tasks against a backend. A conforming server-side BACKEND implements these endpoints and the referenced wire schemas — no VolView client change is needed to bring a new backend online. Everything here is neutral: no backend routes, ids, status enums, or URL shapes leak. The artifact version is the draft artifact version, distinct from the shape versions: the result-intent vocabulary is at version 2 (INTENT_VOCABULARY_VERSION); the task-spec shape at version 1 (specVersion)." + "version": "0.3.0", + "description": "DRAFT 0.x — shapes may change until a second backend passes the conformance kit (the pinned 1.0 criterion). The neutral REST surface the VolView client calls to run processing tasks against a backend. A conforming server-side BACKEND implements these endpoints and the referenced wire schemas — no VolView client change is needed to bring a new backend online. Everything here is neutral: no backend routes, ids, status enums, or URL shapes leak. The artifact version is the draft artifact version, distinct from the shape versions: the result-intent vocabulary is at version 3 (INTENT_VOCABULARY_VERSION); the task-spec shape at version 1 (specVersion)." }, "servers": [ { @@ -1203,7 +1203,7 @@ "properties": { "intent": { "type": "string", - "const": "add-segment-group" + "const": "import-segmentation" }, "id": { "type": "string", diff --git a/backend-contract/generated/result-intent.schema.json b/backend-contract/generated/result-intent.schema.json index e0b27e58b..9ba4f9ec8 100644 --- a/backend-contract/generated/result-intent.schema.json +++ b/backend-contract/generated/result-intent.schema.json @@ -102,7 +102,7 @@ "properties": { "intent": { "type": "string", - "const": "add-segment-group" + "const": "import-segmentation" }, "id": { "type": "string", diff --git a/backend-contract/package.json b/backend-contract/package.json index 1afe62c13..f1a07f920 100644 --- a/backend-contract/package.json +++ b/backend-contract/package.json @@ -1,5 +1,5 @@ { "name": "@volview/backend-contract", - "version": "0.2.0", + "version": "0.3.0", "private": true } diff --git a/backend-contract/processing/__tests__/wire.spec.ts b/backend-contract/processing/__tests__/wire.spec.ts index 603c1bed6..a472b54ef 100644 --- a/backend-contract/processing/__tests__/wire.spec.ts +++ b/backend-contract/processing/__tests__/wire.spec.ts @@ -194,12 +194,12 @@ describe('neutral job status fixtures', () => { // --------------------------------------------------------------------------- describe('result intent fixtures', () => { - it('exports vocabulary version 2 and the exactly-four state intents', () => { - expect(INTENT_VOCABULARY_VERSION).toBe(2); + it('exports vocabulary version 3 and the exactly-four state intents', () => { + expect(INTENT_VOCABULARY_VERSION).toBe(3); expect([...RESULT_INTENTS]).toEqual([ 'add-base-image', 'add-layer', - 'add-segment-group', + 'import-segmentation', 'add-annotations', ]); expect(wire).not.toHaveProperty('intent.download'); @@ -208,8 +208,8 @@ describe('result intent fixtures', () => { it.each([ 'intent.add-base-image', 'intent.add-layer', - 'intent.add-segment-group.with-segments', - 'intent.add-segment-group.embedded', + 'intent.import-segmentation.with-segments', + 'intent.import-segmentation.embedded', 'intent.add-annotations', 'intent.unknown', ])('validates %s', (name) => { @@ -253,11 +253,11 @@ describe('result intent fixtures', () => { ).toBe(false); }); - it('parses add-segment-group WITH segments and a source provenance tag', () => { + it('parses import-segmentation WITH segments and a source provenance tag', () => { const parsed = resultIntentSchema.parse( - wire['intent.add-segment-group.with-segments'] + wire['intent.import-segmentation.with-segments'] ) as Record; - expect(parsed.intent).toBe('add-segment-group'); + expect(parsed.intent).toBe('import-segmentation'); expect(Array.isArray(parsed.segments)).toBe(true); expect(parsed.source).toEqual({ providerId: 'analysis-provider', @@ -266,18 +266,18 @@ describe('result intent fixtures', () => { }); }); - it('parses add-segment-group WITHOUT segments (embedded metadata) but with source', () => { + it('parses import-segmentation WITHOUT segments (embedded metadata) but with source', () => { const parsed = resultIntentSchema.parse( - wire['intent.add-segment-group.embedded'] + wire['intent.import-segmentation.embedded'] ) as Record; - expect(parsed.intent).toBe('add-segment-group'); + expect(parsed.intent).toBe('import-segmentation'); expect(parsed.segments).toBeUndefined(); expect(parsed.source).toMatchObject({ outputId: 'outputLabelmap' }); }); it('rejects a segment-group source without provider identity', () => { const value = structuredClone( - wire['intent.add-segment-group.with-segments'] + wire['intent.import-segmentation.with-segments'] ) as { source: { providerId?: string } }; delete value.source.providerId; expect(knownResultIntentSchema.safeParse(value).success).toBe(false); @@ -373,7 +373,7 @@ describe('result intent fixtures', () => { expect(knownResultIntentSchema.safeParse(short).success).toBe(false); expect(resultIntentSchema.safeParse(short).success).toBe(true); - const good = wire['intent.add-segment-group.with-segments'] as { + const good = wire['intent.import-segmentation.with-segments'] as { segments: { color: number[] }[]; }; const long = structuredClone(good); diff --git a/backend-contract/processing/openapi.ts b/backend-contract/processing/openapi.ts index 8a62458d6..e9bda206d 100644 --- a/backend-contract/processing/openapi.ts +++ b/backend-contract/processing/openapi.ts @@ -474,7 +474,7 @@ export const buildOpenApiDocument = (): Record => ({ // VERSION / specVersion) below. It is deliberately literal, not derived from // the shape-version constants — the artifact and the shapes version on // separate clocks. - version: '0.2.0', + version: '0.3.0', description: 'DRAFT 0.x — shapes may change until a second backend passes the ' + 'conformance kit (the pinned 1.0 criterion). ' + diff --git a/backend-contract/processing/wire.ts b/backend-contract/processing/wire.ts index 27c2d41ba..a834c60bf 100644 --- a/backend-contract/processing/wire.ts +++ b/backend-contract/processing/wire.ts @@ -21,7 +21,7 @@ import { pathSegmentIdSchema } from './ids'; // Bump when the intent vocabulary's shape changes so producers and the applier // can negotiate compatibility. Adding an intent is a compatible bump: an older // client demotes the unknown intent through the fail-open branch above. -export const INTENT_VOCABULARY_VERSION = 2; +export const INTENT_VOCABULARY_VERSION = 3; // --------------------------------------------------------------------------- // Input value: what the client sends at submit @@ -158,7 +158,7 @@ export type NeutralJobStatus = z.infer; export const RESULT_INTENTS = [ 'add-base-image', 'add-layer', - 'add-segment-group', + 'import-segmentation', 'add-annotations', ] as const; export type ResultIntentName = (typeof RESULT_INTENTS)[number]; @@ -212,13 +212,13 @@ const addLayer = z .object({ intent: z.literal('add-layer'), ...resultListItemSchema.shape }) .passthrough(); -// `add-segment-group` carries OPTIONAL `segments` (the bare-labelmap + +// `import-segmentation` carries OPTIONAL `segments` (the bare-labelmap + // labels-sidecar case; a `seg.nrrd` with embedded metadata carries none — the // client uses `segments` when present, else the file's own metadata) and an // optional `source` provenance tag (the idempotency key). const addSegmentGroup = z .object({ - intent: z.literal('add-segment-group'), + intent: z.literal('import-segmentation'), ...resultListItemSchema.shape, segments: z.array(segmentDescriptorSchema).optional(), source: resultSourceSchema.optional(), diff --git a/src/processing/__tests__/applyResults.segments.spec.ts b/src/processing/__tests__/applyResults.segments.spec.ts index 82565a3a1..d34618ce3 100644 --- a/src/processing/__tests__/applyResults.segments.spec.ts +++ b/src/processing/__tests__/applyResults.segments.spec.ts @@ -32,7 +32,7 @@ const existingMask = (imageId: string, name: string) => { const importResult = (segments?: SegmentDescriptor[]) => applyIntent( { - intent: 'add-segment-group', + intent: 'import-segmentation', id: 'result', name: 'output.nrrd', url: 'https://example/output.nrrd', diff --git a/src/processing/__tests__/applyResults.spec.ts b/src/processing/__tests__/applyResults.spec.ts index 8131daf6d..2220231da 100644 --- a/src/processing/__tests__/applyResults.spec.ts +++ b/src/processing/__tests__/applyResults.spec.ts @@ -131,7 +131,7 @@ describe('applyIntent', () => { { value: 2, name: 'tumor', color: rgba(0, 255, 0, 255), visible: false }, ]; await apply( - { intent: 'add-segment-group', ...file, segments }, + { intent: 'import-segmentation', ...file, segments }, context('parent') ); expect(deps.segmentWriter.convertImageToLabelmap).toHaveBeenCalledWith( @@ -143,9 +143,9 @@ describe('applyIntent', () => { expect(deps.openVolumeUrls).not.toHaveBeenCalled(); }); - it('add-segment-group removes the temporarily imported child dataset', async () => { + it('import-segmentation removes the temporarily imported child dataset', async () => { const outcome = await apply( - { intent: 'add-segment-group', ...file }, + { intent: 'import-segmentation', ...file }, context('parent') ); expect(outcome.status).toBe('applied'); @@ -155,12 +155,12 @@ describe('applyIntent', () => { ); }); - it('add-segment-group removes the imported child even when conversion fails', async () => { + it('import-segmentation removes the imported child even when conversion fails', async () => { deps.segmentWriter.convertImageToLabelmap.mockRejectedValue( new Error('bounds do not intersect') ); const outcome = await apply( - { intent: 'add-segment-group', ...file }, + { intent: 'import-segmentation', ...file }, context('parent') ); expect(outcome.status).toBe('failed'); @@ -176,8 +176,8 @@ describe('applyIntent', () => { expect(deps.removeDataset).not.toHaveBeenCalled(); }); - it('add-segment-group with no segments still converts (embedded metadata)', async () => { - await apply({ intent: 'add-segment-group', ...file }, context('parent')); + it('import-segmentation with no segments still converts (embedded metadata)', async () => { + await apply({ intent: 'import-segmentation', ...file }, context('parent')); expect(deps.segmentWriter.convertImageToLabelmap).toHaveBeenCalledWith( 'child-selection', 'parent', @@ -193,7 +193,7 @@ describe('applyIntent', () => { outputId: 'outputLabelmap', }; await apply( - { intent: 'add-segment-group', ...file, source }, + { intent: 'import-segmentation', ...file, source }, context('parent') ); expect(deps.segmentWriter.convertImageToLabelmap).toHaveBeenCalledWith( @@ -213,7 +213,7 @@ describe('applyIntent', () => { deps.segmentWriter.resultSourcesInScene.mockReturnValue([source]); const outcome = await apply( - { intent: 'add-segment-group', ...file, source }, + { intent: 'import-segmentation', ...file, source }, context('parent') ); @@ -229,7 +229,7 @@ describe('applyIntent', () => { deps.segmentWriter.resultSourcesInScene.mockReturnValue([inScene]); const outcome = await apply( - { intent: 'add-segment-group', ...file, source }, + { intent: 'import-segmentation', ...file, source }, context('parent') ); @@ -258,7 +258,7 @@ describe('applyIntent', () => { deps.segmentWriter.resultSourcesInScene.mockReturnValue([undefined]); const outcome = await apply( - { intent: 'add-segment-group', ...file }, + { intent: 'import-segmentation', ...file }, context('parent') ); @@ -266,8 +266,8 @@ describe('applyIntent', () => { expect(deps.segmentWriter.convertImageToLabelmap).toHaveBeenCalledTimes(1); }); - it('add-segment-group with no originating dataset falls back to opening', async () => { - await apply({ intent: 'add-segment-group', ...file }, context(undefined)); + it('import-segmentation with no originating dataset falls back to opening', async () => { + await apply({ intent: 'import-segmentation', ...file }, context(undefined)); expect(deps.segmentWriter.convertImageToLabelmap).not.toHaveBeenCalled(); expect(deps.openVolumeUrls).toHaveBeenCalledWith({ urls: [file.url], @@ -275,10 +275,10 @@ describe('applyIntent', () => { }); }); - it('add-segment-group reports an explicit failure when the result fails to load (#7)', async () => { + it('import-segmentation reports an explicit failure when the result fails to load (#7)', async () => { deps.importVolume.mockResolvedValue(null); const applied = await apply( - { intent: 'add-segment-group', ...file }, + { intent: 'import-segmentation', ...file }, context('parent') ); expect(deps.segmentWriter.convertImageToLabelmap).not.toHaveBeenCalled(); @@ -330,7 +330,7 @@ describe('autoLoadProcessingResults', () => { result({ id: 'b', intent: 'add-layer' }), result({ id: 'c', - intent: 'add-segment-group', + intent: 'import-segmentation', source: { providerId: 'p1', jobId: 'j1', outputId: 'seg' }, segments: [{ value: 1, name: 'liver', color: rgba(1, 2, 3, 4) }], }), @@ -369,7 +369,7 @@ describe('autoLoadProcessingResults', () => { it('opens a parentless segment-group result as an ordinary dataset', async () => { await autoLoad( - [result({ intent: 'add-segment-group' })], + [result({ intent: 'import-segmentation' })], context(undefined) ); expect(deps.segmentWriter.convertImageToLabelmap).not.toHaveBeenCalled(); @@ -386,8 +386,8 @@ describe('autoLoadProcessingResults', () => { .mockResolvedValueOnce([importedComponent({ 1: 'segment-g2' })]); const application = await autoLoad( [ - result({ id: 'a', intent: 'add-segment-group' }), - result({ id: 'b', intent: 'add-segment-group' }), + result({ id: 'a', intent: 'import-segmentation' }), + result({ id: 'b', intent: 'import-segmentation' }), ], context('parent') ); @@ -422,12 +422,12 @@ describe('autoLoadProcessingResults', () => { [ result({ id: 'restored', - intent: 'add-segment-group', + intent: 'import-segmentation', source: restoredSource, }), result({ id: 'new', - intent: 'add-segment-group', + intent: 'import-segmentation', source: newSource, }), ], @@ -448,7 +448,7 @@ describe('autoLoadProcessingResults', () => { describe('autoLoadProcessingResults — labelmap auto-apply', () => { const segResult = (overrides: Partial = {}) => - result({ id: 'seg', intent: 'add-segment-group', ...overrides }); + result({ id: 'seg', intent: 'import-segmentation', ...overrides }); it('auto-applies an importable labelmap', async () => { deps.segmentWriter.convertImageToLabelmap.mockResolvedValue([ @@ -483,7 +483,7 @@ describe('autoLoadProcessingResults — born-persistent (no confirm gate)', () = importedComponent({ 1: 'segment-1' }), ]); await autoLoad( - [result({ id: 'seg', intent: 'add-segment-group', source })], + [result({ id: 'seg', intent: 'import-segmentation', source })], context('parent') ); expect(deps.segmentWriter.convertImageToLabelmap).toHaveBeenCalledWith( diff --git a/src/processing/applyResults.ts b/src/processing/applyResults.ts index 36a73e166..4d4e6b6ed 100644 --- a/src/processing/applyResults.ts +++ b/src/processing/applyResults.ts @@ -45,7 +45,7 @@ type ResultFile = { url: string; name: string }; type SegmentationIntent = Extract< KnownResultIntent, - { intent: 'add-segment-group' } + { intent: 'import-segmentation' } >; type AnnotationsIntent = Extract< KnownResultIntent, @@ -436,7 +436,7 @@ export async function applyIntent( } return { status: 'applied' }; } - case 'add-segment-group': { + case 'import-segmentation': { // Session-restored groups retain their result source. Treat that // durable provenance as an application receipt so retrying Load is // idempotent instead of creating a duplicate group. diff --git a/src/processing/engine/__tests__/resultFiles.spec.ts b/src/processing/engine/__tests__/resultFiles.spec.ts index 838f992aa..a15bdd2e5 100644 --- a/src/processing/engine/__tests__/resultFiles.spec.ts +++ b/src/processing/engine/__tests__/resultFiles.spec.ts @@ -24,7 +24,7 @@ describe('sceneApplicableResults', () => { it('drops a result whose intent name is known but shape is invalid', () => { const malformed = result({ - intent: 'add-segment-group', + intent: 'import-segmentation', segments: [{ value: 0, name: 'bad', color: [0, 0, 0, 255] }], }); expect(sceneApplicableResults([malformed])).toEqual([]); diff --git a/src/processing/engine/__tests__/resultToIntent.spec.ts b/src/processing/engine/__tests__/resultToIntent.spec.ts index 8e7d21729..d0a063003 100644 --- a/src/processing/engine/__tests__/resultToIntent.spec.ts +++ b/src/processing/engine/__tests__/resultToIntent.spec.ts @@ -19,7 +19,7 @@ describe('resultToIntent', () => { [ 'malformed', result({ - intent: 'add-segment-group', + intent: 'import-segmentation', segments: [{ value: 0, name: 'bad', color: [0, 0, 0, 255] }], }), ], diff --git a/src/processing/engine/__tests__/wire.spec.ts b/src/processing/engine/__tests__/wire.spec.ts index 3d6f27c02..041d679fa 100644 --- a/src/processing/engine/__tests__/wire.spec.ts +++ b/src/processing/engine/__tests__/wire.spec.ts @@ -160,7 +160,7 @@ describe('parseResults', () => { id: 'r1', name: 'seg.nrrd', url: 'https://example/seg.nrrd', - intent: 'add-segment-group', + intent: 'import-segmentation', segments: [{ value: 1, name: 'liver', color: [255, 0, 0, 255] }], extra: 'keep-me', }, @@ -176,7 +176,7 @@ describe('parseResults', () => { id: 'r1', name: 'seg.nrrd', url: 'https://example/seg.nrrd', - intent: 'add-segment-group', + intent: 'import-segmentation', segments: [{ value: 0, name: 'bg', color: [300, -5, 0, 255] }], }, ]; From fe336717ebfbdef0e1aa6ce04ea5dfe5ba60fdf5 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 15 Sep 2026 15:34:49 -0400 Subject: [PATCH 02/39] feat(segmentation): choose labelmap bit depth from label count Composed labelmaps use unsigned 8-bit voxels for up to 255 labels and unsigned 16-bit voxels for 256 through 65535. An imported 16-bit labelmap splits into byte masks in one sweep. Fractional, NaN and infinite voxel values are excluded during import. --- .../__tests__/boundedMaskRoundTrip.spec.ts | 36 +++-- src/processing/components/JobsModule.vue | 7 +- .../components/__tests__/JobsModule.spec.ts | 2 +- src/segmentation/README.md | 11 ++ .../__tests__/annotationToolSegments.spec.ts | 2 +- .../__tests__/boundedSegmentMasks.spec.ts | 82 +----------- .../__tests__/paintTarget.spec.ts | 4 +- .../__tests__/segmentMaskComposite.spec.ts | 72 ++++++---- .../__tests__/segmentVoxels.spec.ts | 2 +- .../components/SaveSegmentationDialog.vue | 24 +++- .../io/__tests__/labelmap.spec.ts | 86 ++++++++++++ src/segmentation/io/composition.ts | 75 +++-------- src/segmentation/io/export.ts | 4 +- src/segmentation/io/import.ts | 112 ++++++---------- src/segmentation/io/labelmap.ts | 63 +++++++++ src/segmentation/io/stateFile.ts | 17 +-- src/segmentation/masks/labelValue.ts | 26 ---- src/segmentation/masks/overlap.ts | 2 +- src/segmentation/masks/storage.ts | 6 +- src/segmentation/masks/voxelAccess.ts | 27 +--- src/segmentation/store.ts | 114 +++++++--------- tests/specs/adaptive-labelmap.e2e.ts | 124 ++++++++++++++++++ 22 files changed, 521 insertions(+), 377 deletions(-) create mode 100644 src/segmentation/io/__tests__/labelmap.spec.ts create mode 100644 src/segmentation/io/labelmap.ts create mode 100644 tests/specs/adaptive-labelmap.e2e.ts diff --git a/src/io/state-file/__tests__/boundedMaskRoundTrip.spec.ts b/src/io/state-file/__tests__/boundedMaskRoundTrip.spec.ts index ed65a952a..cab0195b4 100644 --- a/src/io/state-file/__tests__/boundedMaskRoundTrip.spec.ts +++ b/src/io/state-file/__tests__/boundedMaskRoundTrip.spec.ts @@ -97,6 +97,18 @@ const restoredSegment = (name: string) => (segment) => nameOf(segment) === name )!; +// Tumor's mask starts at parent index [1, 1, 1]. +const expectTumorOnParentGrid = () => { + const mask = store().maskVoxels(restoredSegment('Tumor').id).image(); + const parent = parentImage('new-1'); + expect(Array.from(mask.indexToWorld([1, 0, 0] as never))).toEqual( + Array.from(parent.indexToWorld([2, 1, 1] as never)) + ); + expect(Array.from(mask.getDirection())).toEqual( + Array.from(parent.getDirection()) + ); +}; + async function buildScene() { await seatImage('img-1', { ...GRID, name: 'CT A' }); await seatImage('img-2', { ...GRID, name: 'CT B' }); @@ -519,16 +531,22 @@ describe('bounded masks through the state file', () => { await roundTrip(inMemoryArtifactIO()); - const tumor = listMasks(store().getSegmentationForImage('new-1')!).find( - (segment) => nameOf(segment) === 'Tumor' - )!; - const mask = store().maskVoxels(tumor.id).image(); - expect(Array.from(mask.indexToWorld([0, 0, 0] as never))).toEqual( - Array.from(parentImage('new-1').indexToWorld([1, 1, 1] as never)) - ); - expect(Array.from(mask.getSpacing())).toEqual( - Array.from(parentImage('new-1').getSpacing()) + expectTumorOnParentGrid(); + }); + + it('puts a mask on the parent grid whatever geometry its codec kept', async () => { + await buildScene(); + const io = inMemoryArtifactIO(); + + // A codec that stores no spacing or direction reads back other ones. + await roundTrip(io, () => + io.written.forEach((labelmap) => { + labelmap.setSpacing([1, 1, 1]); + labelmap.setDirection([0, 1, 0, 1, 0, 0, 0, 0, 1]); + }) ); + + expectTumorOnParentGrid(); }); }); diff --git a/src/processing/components/JobsModule.vue b/src/processing/components/JobsModule.vue index 24f32eafc..1cfcf5c7e 100644 --- a/src/processing/components/JobsModule.vue +++ b/src/processing/components/JobsModule.vue @@ -124,7 +124,7 @@ diff --git a/src/segmentation/components/SegmentList.vue b/src/segmentation/components/SegmentList.vue index 9c4f78aa8..27f1a06b0 100644 --- a/src/segmentation/components/SegmentList.vue +++ b/src/segmentation/components/SegmentList.vue @@ -15,7 +15,7 @@ import { useSegmentEditing } from '@/src/segmentation/composables/useSegmentEdit import { pulseSegmentMask } from '@/src/segmentation/rendering/revealPulse'; import { revealSegmentContent } from '@/src/core/annotations/locator'; import { isCineImage } from '@/src/core/cine/isCineImage'; -import { NO_NAME, SEGMENT_SHORTCUT_ACTIONS } from '@/src/constants'; +import { SEGMENT_SHORTCUT_ACTIONS } from '@/src/constants'; import { actionToKey, readableBinding, @@ -27,9 +27,9 @@ import useLoadDataStore from '@/src/store/load-data'; import type { LPSAxis } from '@/src/types/lps'; import { DEFAULT_SEGMENTATION_DISPLAY, - listMasks, maskHasContent, maskScalars, + segmentationHasContent, type SegmentMask, type SegmentationDisplayPatch, } from '@/src/segmentation/model'; @@ -85,7 +85,7 @@ const rows = computed((previous?: Row[]) => { shortcut: SEGMENT_SHORTCUT_ACTIONS[index] ? readableBinding(actionToKey.value[SEGMENT_SHORTCUT_ACTIONS[index]]) : undefined, - name: appearance.name || NO_NAME, + name: appearance.displayName, color: appearance.cssColor, visible: appearance.visible, locked: appearance.locked, @@ -135,7 +135,7 @@ const savableReason = computed(() => { const segmentation = viewedSegmentation.value; // Records alone save nothing: a segment resolved here but never painted // leaves an empty mask, so what is offered follows the voxels. - if (!segmentation || !listMasks(segmentation).some(maskHasContent)) + if (!segmentation || !segmentationHasContent(segmentation)) return 'Nothing is painted on this image yet'; return ''; }); diff --git a/src/segmentation/composables/useMaskRevision.ts b/src/segmentation/composables/useMaskRevision.ts index ba0a4db3b..8183cc0df 100644 --- a/src/segmentation/composables/useMaskRevision.ts +++ b/src/segmentation/composables/useMaskRevision.ts @@ -2,17 +2,18 @@ import { ref, watchEffect } from 'vue'; import { useSegmentationStore } from '@/src/segmentation/store'; import { listMasks } from '@/src/segmentation/model'; +import type { Maybe } from '@/src/types'; /** - * A counter every mask change bumps, voxel writes included. A mask's extent is - * reactive but a write inside the box it already has moves nothing, so this is - * the only trace of one a consumer can watch. It says something changed and - * nothing about what. A stroke bumps it once per changed mask per sample, - * so debounce anything expensive that reads it. + * A counter every change to one image's masks bumps, voxel writes included. A + * mask's extent is reactive but a write inside the box it already has moves + * nothing, so this is the only trace of one a consumer can watch. It says + * something changed and nothing about what. A stroke bumps it once per changed + * mask per sample, so debounce anything expensive that reads it. * * Scoped to the caller: the masks are watched only while it is alive. */ -export function useMaskRevision() { +export function useMaskRevision(imageId: () => Maybe) { const segmentationStore = useSegmentationStore(); const revision = ref(0); @@ -20,9 +21,12 @@ export function useMaskRevision() { // announces itself to vtk, so watching the mask itself catches the ones that // reach the buffer without going through the store. watchEffect((onCleanup) => { - const subscriptions = Object.values(segmentationStore.segmentations) - .flatMap((segmentation) => listMasks(segmentation)) - .flatMap((segment) => segment.representations.labelmap ?? []) + const id = imageId(); + const segmentation = id + ? segmentationStore.getSegmentationForImage(id) + : undefined; + const subscriptions = (segmentation ? listMasks(segmentation) : []) + .flatMap((mask) => mask.representations.labelmap ?? []) .map((binding) => binding.image.onModified(() => { revision.value += 1; diff --git a/src/segmentation/composables/useSegmentShapes.ts b/src/segmentation/composables/useSegmentShapes.ts index 96b123d3f..515f8c3da 100644 --- a/src/segmentation/composables/useSegmentShapes.ts +++ b/src/segmentation/composables/useSegmentShapes.ts @@ -91,8 +91,7 @@ const useSegmentShapesStore = defineStore('segmentShapes', () => { }); /** - * The shapes drawn on the viewed image, grouped by the segment each one names. - * A segment's row lists these under it, so the sidebar holds no second list of - * the same annotations. + * Finished shapes on the viewed image, shared by the Measurements list and the + * segment rows' reveal. */ export const useSegmentShapes = () => useSegmentShapesStore().segmentShapes; diff --git a/src/segmentation/io/__tests__/export.spec.ts b/src/segmentation/io/__tests__/export.spec.ts index fbbb7a658..05be83fad 100644 --- a/src/segmentation/io/__tests__/export.spec.ts +++ b/src/segmentation/io/__tests__/export.spec.ts @@ -1,7 +1,13 @@ -import { describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; import JSZip from 'jszip'; -import { bundleExportFiles, layerFileName } from '@/src/segmentation/io/export'; +import { + bundleExportFiles, + layerFileName, + segmentationFileStem, +} from '@/src/segmentation/io/export'; +import { useDICOMStore } from '@/src/store/datasets-dicom'; // A labelmap file carries one label per voxel, so a segmentation with overlap // leaves as several files. One file is the common case and stays the download @@ -27,6 +33,31 @@ describe('naming the file each group of segments writes', () => { }); }); +describe('the stem a segmentation is saved and staged under', () => { + beforeEach(() => setActivePinia(createPinia())); + + it('drops a file-backed image name’s extension, compound ones included', () => { + expect(segmentationFileStem('file-image', 'scan.nii.gz')).toBe('scan'); + }); + + it('keeps every dot of a DICOM series name', () => { + useDICOMStore().volumeInfo['series-1'] = { + NumberOfSlices: 1, + VolumeID: 'series-1', + Modality: 'MR', + SeriesInstanceUID: '1.2.3.4', + SeriesNumber: '1', + SeriesDescription: 'Ax T2 FSE 3.5mm', + WindowLevel: '128', + WindowWidth: '256', + }; + + expect(segmentationFileStem('series-1', 'Ax T2 FSE 3.5mm')).toBe( + 'Ax T2 FSE 3.5mm' + ); + }); +}); + describe('handing the written files to the browser', () => { it('downloads a single file as itself', async () => { const bundle = await bundleExportFiles('Prostate', [ diff --git a/src/segmentation/io/composition.ts b/src/segmentation/io/composition.ts index 1b5b6ae1d..e9e87d196 100644 --- a/src/segmentation/io/composition.ts +++ b/src/segmentation/io/composition.ts @@ -1,9 +1,13 @@ +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; import { useSegmentationEditsStore } from '@/src/segmentation/editing/coordinator'; import { useImageCacheStore } from '@/src/store/image-cache'; import { useSegmentStore } from '@/src/segmentation/segments'; import { useSegmentationStore } from '@/src/segmentation/store'; -import { groupByLayer, writeMaskInto } from '@/src/segmentation/masks/overlap'; -import { boundedMask } from '@/src/segmentation/masks/voxelAccess'; +import { + boundScalars, + groupByLayer, + writeMaskInto, +} from '@/src/segmentation/masks/overlap'; import { allocateLabelmap, labelmapScalars, @@ -26,38 +30,84 @@ export function compositeLabelmap( members?: SegmentMask[] ) { useSegmentationEditsStore().beforeRead(); - const imageCacheStore = useImageCacheStore(); - const segmentRegistry = useSegmentStore().segments; - const imageMasks = useSegmentationStore().imageMasks; - const parent = imageCacheStore.getVtkImageData(parentImageId); - if (!parent) throw new Error('No such parent image'); - - const dimensions = parent.getDimensions(); + const snapshot = captureLabelmapParts(parentImageId, [ + members ?? useSegmentationStore().imageMasks(parentImageId), + ]); + return composeLabelmapPart(snapshot.parent, snapshot.parts[0]); +} - const included = [...(members ?? imageMasks(parentImageId))].sort( - (first, second) => - segmentRegistry.orderIndexOf(first.segmentId) - - segmentRegistry.orderIndexOf(second.segmentId) - ); - const labelmap = allocateLabelmap(parent, included.length); - const values = labelmapScalars(labelmap); - const segments = included.map((segment, index) => - toLabelmapSegment(segmentRegistry.getSegment(segment.segmentId), index + 1) - ); - [...included].reverse().forEach((segment, index) => { - const bounded = boundedMask(segment.representations.labelmap); - const labelValue = segments[included.length - 1 - index].value; - if (bounded) writeMaskInto(values, dimensions, bounded, labelValue); +/** Snapshot bounded geometry and appearance without allocating full-volume parts. */ +export function captureLabelmapParts( + parentImageId: string, + parts: SegmentMask[][] +) { + const source = useImageCacheStore().getVtkImageData(parentImageId); + if (!source) throw new Error('No such parent image'); + const parent = vtkImageData.newInstance({ + origin: [...source.getOrigin()], + spacing: [...source.getSpacing()], + direction: [...source.getDirection()], }); + parent.setDimensions(source.getDimensions()); + parent.computeTransforms(); + const registry = useSegmentStore().segments; + return { + parent, + parts: parts.map((part) => + [...part] + .sort( + (a, b) => + registry.orderIndexOf(a.segmentId) - + registry.orderIndexOf(b.segmentId) + ) + .map((mask, index) => { + const bounded = boundScalars(mask.representations.labelmap); + return { + descriptor: toLabelmapSegment( + registry.getSegment(mask.segmentId), + index + 1 + ), + bounded: bounded && { + ...bounded, + scalars: bounded.scalars.slice(), + }, + }; + }) + ), + }; +} + +type CapturedPart = ReturnType['parts'][number]; - return { labelmap, segments }; +export function composeLabelmapPart(parent: vtkImageData, part: CapturedPart) { + const labelmap = allocateLabelmap(parent, part.length); + const values = labelmapScalars(labelmap); + for (const { descriptor, bounded } of [...part].reverse()) { + if (bounded) + writeMaskInto(values, parent.getDimensions(), bounded, descriptor.value); + } + return { labelmap, segments: part.map(({ descriptor }) => descriptor) }; } /** Plan overlap-free files and retain why more than one file is necessary. */ -export function planLabelmapExport(parentImageId: string) { - const layers = groupByLayer( - useSegmentationStore().imageMasks(parentImageId), - (segment) => boundedMask(segment.representations.labelmap) +export function planLabelmapExport( + parentImageId: string, + preferredSegmentId?: string +) { + const registry = useSegmentStore().segments; + const masks = [...useSegmentationStore().imageMasks(parentImageId)].sort( + (a, b) => { + if (a.segmentId === preferredSegmentId) return -1; + if (b.segmentId === preferredSegmentId) return 1; + return ( + registry.orderIndexOf(a.segmentId) - registry.orderIndexOf(b.segmentId) + ); + } + ); + // A mask holding no voxels still takes a label value and ships as an empty + // segment: a consumer that declared the bin gets to see it came back empty. + const layers = groupByLayer(masks, (mask) => + boundScalars(mask.representations.labelmap) ); const parts = layers.flatMap((layer) => chunk(layer, LABELMAP_MAX_VALUE)); return { diff --git a/src/segmentation/io/export.ts b/src/segmentation/io/export.ts index 89b9ea91f..2856fadbe 100644 --- a/src/segmentation/io/export.ts +++ b/src/segmentation/io/export.ts @@ -1,4 +1,13 @@ import JSZip from 'jszip'; +import { + captureLabelmapParts, + composeLabelmapPart, +} from '@/src/segmentation/io/composition'; +import { writeSegmentation } from '@/src/io/readWriteImage'; +import { sanitizeSegmentationFileStem } from '@/src/io/state-file/maskArchivePath'; +import type { SegmentMask } from '@/src/segmentation/model'; +import { isRegularImage } from '@/src/utils/dataSelection'; +import { stripExtension } from '@/src/utils/path'; export type ExportFile = { name: string; @@ -14,6 +23,33 @@ export const layerFileName = (stem: string, format: string, layer: number) => export const archiveNameFor = (stem: string) => `${stem}.zip`; +/** + * The stem a segmentation is saved and staged under. Its name starts as its + * image's, so a file-backed one drops the file's extension, and a DICOM series + * name stays whole: its dots are not an extension. + */ +export const segmentationFileStem = (parentImageId: string, name: string) => + sanitizeSegmentationFileStem( + isRegularImage(parentImageId) ? stripExtension(name) : name + ); + +export async function writeLabelmapParts( + { parentId, parts }: { parentId: string; parts: SegmentMask[][] }, + stem: string, + format: string, + deliver: (file: ExportFile) => Promise | void +) { + // Captured before the first await, so an edit made meanwhile misses the files. + const snapshot = captureLabelmapParts(parentId, parts); + // One at a time: serializing copies the whole buffer, and itk-wasm queues + // the writes on one shared worker whatever the caller does. + for (const [index, part] of snapshot.parts.entries()) { + const { labelmap, segments } = composeLabelmapPart(snapshot.parent, part); + const data = await writeSegmentation(format, labelmap, segments); + await deliver({ name: layerFileName(stem, format, index), data }); + } +} + /** * What a save hands to the browser: the single file itself, or every file in * one archive. A labelmap file carries one label per voxel, so segments that diff --git a/src/segmentation/masks/overlap.ts b/src/segmentation/masks/overlap.ts index aaa5220bf..929e5b36c 100644 --- a/src/segmentation/masks/overlap.ts +++ b/src/segmentation/masks/overlap.ts @@ -27,13 +27,14 @@ export type BoundedScalars = MaskBounds & { * The extent is copied because the callers read it per voxel and a segment's * own copy lives in the reactive tree. */ -export function boundScalars( - mask: vtkLabelMap | undefined, - bounds: Extent3D -): BoundedScalars | undefined { - if (!mask || isEmptyExtent(bounds)) return undefined; - const extent = [...bounds] as Extent3D; +export function boundScalars(binding?: { + image: vtkLabelMap; + extent: Extent3D; +}) { + if (!binding || isEmptyExtent(binding.extent)) return undefined; + const extent = [...binding.extent] as Extent3D; const [mi, mj] = extentSize(extent); + const mask = binding.image; return { mask, scalars: maskScalars(mask), extent, mi, mj }; } diff --git a/src/segmentation/masks/voxelAccess.ts b/src/segmentation/masks/voxelAccess.ts index 9de21a6f2..6efe961b4 100644 --- a/src/segmentation/masks/voxelAccess.ts +++ b/src/segmentation/masks/voxelAccess.ts @@ -38,9 +38,6 @@ type VoxelAccessDeps = { overlapAllowed: () => boolean; }; -export const boundedMask = (binding?: LabelmapBinding) => - binding && boundScalars(binding.image, binding.extent); - /** * Reading and growing the voxels behind a mask. Split out so the store holds * the records; every accessor re-resolves its binding rather than capturing a @@ -161,7 +158,9 @@ export function createVoxelAccess(deps: VoxelAccessDeps) { others ); const bounded = (masks: SegmentMask[]) => - masks.flatMap((mask) => boundedMask(mask.representations.labelmap) ?? []); + masks.flatMap( + (mask) => boundScalars(mask.representations.labelmap) ?? [] + ); return { takeFrom: bounded(takeFrom), yieldTo: bounded(yieldTo) }; } diff --git a/src/segmentation/model.ts b/src/segmentation/model.ts index 4d069c952..6bf23c101 100644 --- a/src/segmentation/model.ts +++ b/src/segmentation/model.ts @@ -143,6 +143,10 @@ export function listMasks(segmentation: Segmentation) { return segmentation.order.map((id) => segmentation.masks[id]); } +/** Whether saving or staging this segmentation would write a voxel. */ +export const segmentationHasContent = (segmentation: Segmentation) => + listMasks(segmentation).some(maskHasContent); + /** * Aimed writes take voxels from unlocked neighbors and go around locked ones, * or leave every neighbor alone while overlap is allowed. Sweeps only grow into diff --git a/src/segmentation/segment.ts b/src/segmentation/segment.ts index f1ff545ab..77e2b7759 100644 --- a/src/segmentation/segment.ts +++ b/src/segmentation/segment.ts @@ -4,6 +4,7 @@ import { STROKE_WIDTH_ANNOTATION_TOOL_DEFAULT, TOOL_COLORS, } from '@/src/config'; +import { NO_NAME } from '@/src/constants'; import type { Maybe } from '@/src/types'; import { cleanUndefined } from '@/src/utils'; import type { LabelmapSegment } from '@/src/segmentation/model'; @@ -61,7 +62,12 @@ export const resolveSegmentAppearance = (segment: Maybe) => { strokeWidth: stated.strokeWidth, }), }; - return { ...resolved, cssColor: rgbaToCssColor(resolved.color) }; + return { + ...resolved, + // What every list and label shows for a segment whose name is empty. + displayName: resolved.name || NO_NAME, + cssColor: rgbaToCssColor(resolved.color), + }; }; /** diff --git a/src/store/tools/useAnnotationTool.ts b/src/store/tools/useAnnotationTool.ts index 62b2d6204..0ee6b50b4 100644 --- a/src/store/tools/useAnnotationTool.ts +++ b/src/store/tools/useAnnotationTool.ts @@ -1,4 +1,4 @@ -import { Ref, computed, markRaw, ref } from 'vue'; +import { Ref, computed, ref } from 'vue'; import type { Vector3 } from '@kitware/vtk.js/types'; import type { Maybe, PartialWithRequired, UnwrapAll } from '@/src/types'; import { isRecord, removeFromArray } from '@/src/utils'; @@ -237,7 +237,6 @@ export const useAnnotationTool = < }); return { - segments: markRaw(registry), appearanceOfTool, toolIDs, toolByID, diff --git a/tests/specs/multiple-segmentation-import.e2e.ts b/tests/specs/multiple-segmentation-import.e2e.ts index c88aef3a1..19af564ca 100644 --- a/tests/specs/multiple-segmentation-import.e2e.ts +++ b/tests/specs/multiple-segmentation-import.e2e.ts @@ -45,9 +45,14 @@ const addAsSegmentation = async (name: string) => { const card = $(`.v-card:has([title="${name}"])`); await card.$('button.dataset-menu').click(); const menuItem = $( - `//*[contains(@class,"v-overlay--active")]//*[contains(@class,"v-list-item") and contains(normalize-space(.),"Add as segmentation")]` + '//*[contains(@class,"v-overlay--active")]' + + '//*[contains(concat(" ",normalize-space(@class)," ")," v-list-item ")' + + ' and normalize-space(.)="Add as segmentation"]' ); - await menuItem.$('.v-list-item__content').click(); + await menuItem.waitForDisplayed(); + await menuItem.waitForStable(); + await menuItem.waitForClickable(); + await menuItem.click(); await card .$('[data-testid="segmentation-conversion-progress"]') .waitForDisplayed({ reverse: true }); @@ -108,8 +113,8 @@ describe('Importing overlapping files with the same Slicer segment name', functi await volViewPage.clickSaveSegmentsButton(); const notice = $('[data-testid="save-overlap-notice"]'); await expect(notice).toBeDisplayed(); - expect(await notice.getText()).toBe( - 'Saving 2 files due to overlap, bundled into multi-import-parent.nrrd.zip.' + await expect(notice).toHaveText( + 'Saving 2 files due to overlap, bundled into multi-import-parent.zip.' ); if (process.env.CAPTURE_SEGMENT_IMPORT_DEMO) { const demoDir = path.join(projectRoot(), '.tmp', 'demo'); From 9689a889344915855763defd07a960c26d7ad8e7 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Sat, 19 Sep 2026 15:10:43 +0000 Subject: [PATCH 04/39] refactor(segmentation): simplify editing and clarify registry ordering --- src/components/EditableItemList.vue | 232 ++++++------------ src/core/tools/paint/index.ts | 55 ++--- src/io/state-file/schema.ts | 10 +- src/segmentation/README.md | 8 +- .../__tests__/registryOrderPromises.spec.ts | 59 +++++ .../__tests__/segmentRegistry.spec.ts | 2 +- src/segmentation/components/SegmentList.vue | 30 +-- .../components/__tests__/SegmentList.spec.ts | 12 +- .../__tests__/segmentPanelSurface.spec.ts | 3 +- .../editing/algorithms/fillHoles.ts | 4 +- src/segmentation/editing/rasterizePolygon.ts | 16 +- src/segmentation/geometry.ts | 22 ++ src/segmentation/io/composition.ts | 7 +- src/segmentation/segment.ts | 14 +- src/segmentation/segmentRegistry.ts | 5 +- src/store/tools/paint.ts | 42 ++-- src/utils/index.ts | 18 ++ 17 files changed, 264 insertions(+), 275 deletions(-) create mode 100644 src/segmentation/__tests__/registryOrderPromises.spec.ts diff --git a/src/components/EditableItemList.vue b/src/components/EditableItemList.vue index c520e58fc..60fb9bdfd 100644 --- a/src/components/EditableItemList.vue +++ b/src/components/EditableItemList.vue @@ -1,50 +1,22 @@ - diff --git a/src/segmentation/components/__tests__/ProcessWorkflow.spec.ts b/src/segmentation/components/__tests__/ProcessWorkflow.spec.ts index c6c20941e..dd62db242 100644 --- a/src/segmentation/components/__tests__/ProcessWorkflow.spec.ts +++ b/src/segmentation/components/__tests__/ProcessWorkflow.spec.ts @@ -1,13 +1,17 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { createPinia, setActivePinia } from 'pinia'; -import { createApp, defineComponent, nextTick } from 'vue'; -import { flushPromises, mount, VueWrapper } from '@vue/test-utils'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { defineComponent, nextTick } from 'vue'; +import { + enableAutoUnmount, + flushPromises, + mount, + VueWrapper, +} from '@vue/test-utils'; import ProcessWorkflow from '@/src/segmentation/components/ProcessWorkflow.vue'; -import { CorePiniaProviderPlugin } from '@/src/core/provider'; import { addActiveSegment, - seatImage, + activateAppPinia, + viewImage, } from '@/src/segmentation/__tests__/segmentMaskFixtures'; import type { ProcessTarget } from '@/src/segmentation/editing/paintProcess'; import { useViewStore } from '@/src/store/views'; @@ -15,11 +19,7 @@ import { useToolStore } from '@/src/store/tools'; import { Tools } from '@/src/store/tools/types'; import { markCine } from '@/src/core/cine/__tests__/cineFixtures'; -// --------------------------------------------------------------------------- -// The Original/Processed pair is a segmented choice, not a switch: the toggle -// is mandatory, so clicking the button already selected keeps the selection -// but still fires the click. Each button therefore states what it shows. -// --------------------------------------------------------------------------- +enableAutoUnmount(afterEach); const BtnStub = defineComponent({ name: 'VBtn', @@ -67,63 +67,52 @@ const selected = (wrapper: VueWrapper) => wrapper.get('.btn-toggle').attributes('data-selected'); beforeEach(async () => { - const pinia = createPinia().use(CorePiniaProviderPlugin()); - createApp({}).use(pinia); - setActivePinia(pinia); - await seatImage('image-1', { dimensions: [2, 1, 1] }); - useViewStore().setDataForAllViews('image-1'); - await nextTick(); + activateAppPinia(); + await viewImage('image-1', { dimensions: [2, 1, 1] }); useToolStore().setCurrentTool(Tools.Paint); }); describe('the process preview toggle', () => { const previewing = async () => { - const { labelMap } = addActiveSegment(new Uint8Array([1, 0])); + addActiveSegment(new Uint8Array([1, 0])); const wrapper = mount(ProcessWorkflow, { props: { algorithm: processed }, global: globalOptions, }); await button(wrapper, 'Preview').trigger('click'); await flushPromises(); - const values = () => - Array.from(labelMap.getPointData().getScalars().getData()); expect(wrapper.find('.btn-toggle').exists()).toBe(true); - return { wrapper, values }; + return wrapper; }; it('leaves the preview alone when the showing button is clicked again', async () => { - const { wrapper, values } = await previewing(); + const wrapper = await previewing(); expect(selected(wrapper)).toBe('1'); - expect(values()).toEqual([1, 1]); await button(wrapper, 'Processed').trigger('click'); expect(selected(wrapper)).toBe('1'); - expect(values()).toEqual([1, 1]); }); it('shows the original once, however often its button is clicked', async () => { - const { wrapper, values } = await previewing(); + const wrapper = await previewing(); await button(wrapper, 'Original').trigger('click'); expect(selected(wrapper)).toBe('0'); - expect(values()).toEqual([1, 0]); await button(wrapper, 'Original').trigger('click'); expect(selected(wrapper)).toBe('0'); - expect(values()).toEqual([1, 0]); }); it('still moves between the two', async () => { - const { wrapper, values } = await previewing(); + const wrapper = await previewing(); await button(wrapper, 'Original').trigger('click'); await button(wrapper, 'Processed').trigger('click'); expect(selected(wrapper)).toBe('1'); - expect(values()).toEqual([1, 1]); }); }); diff --git a/src/segmentation/components/__tests__/SegmentAssignmentList.spec.ts b/src/segmentation/components/__tests__/SegmentAssignmentList.spec.ts index bb45353bd..a007aadaf 100644 --- a/src/segmentation/components/__tests__/SegmentAssignmentList.spec.ts +++ b/src/segmentation/components/__tests__/SegmentAssignmentList.spec.ts @@ -12,7 +12,9 @@ const globalOptions = { stubs: { VList: { template: '
' }, VListItem: { - template: '
  • ', + props: ['active'], + template: + '
  • ', }, VListItemTitle: { template: '' }, }, @@ -35,4 +37,21 @@ describe('segment assignment list', () => { NO_NAME, ]); }); + + it('marks the current segment and emits the chosen segment id', async () => { + const registry = useSegmentStore().segments; + const first = registry.mintSegment({ name: 'Tumor' }); + const second = registry.mintSegment({ name: 'Node' }); + const wrapper = mount(SegmentAssignmentList, { + props: { segmentId: first }, + global: globalOptions, + }); + const items = wrapper.findAll('.item'); + expect(items.map((item) => item.attributes('aria-selected'))).toEqual([ + 'true', + 'false', + ]); + await items[1].trigger('click'); + expect(wrapper.emitted('select')).toEqual([[second]]); + }); }); diff --git a/src/segmentation/components/__tests__/SegmentEditor.spec.ts b/src/segmentation/components/__tests__/SegmentEditor.spec.ts index e68ea71d8..d41a9bc17 100644 --- a/src/segmentation/components/__tests__/SegmentEditor.spec.ts +++ b/src/segmentation/components/__tests__/SegmentEditor.spec.ts @@ -1,9 +1,11 @@ import { defineComponent } from 'vue'; -import { mount, type VueWrapper } from '@vue/test-utils'; -import { describe, expect, it } from 'vitest'; +import { enableAutoUnmount, mount, type VueWrapper } from '@vue/test-utils'; +import { afterEach, describe, expect, it } from 'vitest'; import SegmentEditor from '@/src/segmentation/components/SegmentEditor.vue'; +enableAutoUnmount(afterEach); + const Shell = { template: '
    ' }; const ButtonStub = defineComponent({ @@ -15,7 +17,9 @@ const ButtonStub = defineComponent({ const TextFieldStub = defineComponent({ name: 'VTextField', props: ['modelValue', 'rules'], - template: '', + emits: ['update:modelValue'], + template: + '', }); const SliderStub = defineComponent({ @@ -68,7 +72,7 @@ describe('segment editor name validation', () => { expect(rule('Tumor')).toBe(true); }); - it('rejects changing to another segment’s name', async () => { + it("rejects changing to another segment's name", async () => { const wrapper = mountEditor(); await wrapper.setProps({ name: ' Node ' }); @@ -149,3 +153,29 @@ describe('segment editor stroke width', () => { expect(wrapper.emitted('update:strokeWidth')).toEqual([[4]]); }); }); + +describe('segment editor field events', () => { + it.each([ + ['Fill Opacity', 'update:fillOpacity'], + ['Outline Opacity', 'update:outlineOpacity'], + ])('emits %s changes', (name, event) => { + const wrapper = mountEditor(); + const slider = wrapper + .findAllComponents(SliderStub) + .find((candidate) => candidate.props('name') === name)!; + slider.vm.$emit('update:modelValue', 0.35); + expect(wrapper.emitted(event)).toEqual([[0.35]]); + }); + + it('emits a name change from the name field', async () => { + const wrapper = mountEditor(); + await wrapper.get('input').setValue('Lesion'); + expect(wrapper.emitted('update:name')).toEqual([['Lesion']]); + }); + + it('finishes from Enter in the name field', async () => { + const wrapper = mountEditor(); + await wrapper.get('input').trigger('keydown', { key: 'Enter' }); + expect(wrapper.emitted('done')).toEqual([[]]); + }); +}); diff --git a/src/segmentation/components/__tests__/SegmentList.spec.ts b/src/segmentation/components/__tests__/SegmentList.spec.ts index 28cf1e90d..255af008e 100644 --- a/src/segmentation/components/__tests__/SegmentList.spec.ts +++ b/src/segmentation/components/__tests__/SegmentList.spec.ts @@ -6,55 +6,36 @@ import { lockSegment, seedVoxel, seatImage, + showImage, + mintSegment, seatSpecImage, store, } from '@/src/segmentation/__tests__/segmentMaskFixtures'; -import { defineComponent, nextTick, ref } from 'vue'; +import { defineComponent, nextTick } from 'vue'; import { enableAutoUnmount, mount, VueWrapper } from '@vue/test-utils'; import SegmentList from '@/src/segmentation/components/SegmentList.vue'; -import { useMessageStore } from '@/src/store/messages'; +import { messageTitles } from '@/src/components/__tests__/messageDisplay'; import useLoadDataStore from '@/src/store/load-data'; import { useSegmentStore } from '@/src/segmentation/segments'; -import { DEFAULT_SEGMENTATION_FILL_OPACITY } from '@/src/segmentation/model'; -import { useViewStore } from '@/src/store/views'; +import { DEFAULT_SEGMENTATION_DISPLAY } from '@/src/segmentation/model'; import { seatCineImage } from '@/src/core/cine/__tests__/cineFixtures'; -import { - useCurrentTools, - usePlacingAnnotationTool, -} from '@/src/composables/annotationTool'; import { useRulerStore } from '@/src/store/tools/rulers'; -import { useRectangleStore } from '@/src/store/tools/rectangles'; -import { usePolygonStore } from '@/src/store/tools/polygons'; import { AXIAL_FRAME_OF_REFERENCE } from '@/src/utils/frameOfReference'; enableAutoUnmount(afterEach); -// --------------------------------------------------------------------------- -// One flat list of segments: rows are the shared registry's segments, keyed -// on segment id, offered whether or not this image has a mask for them. The -// visibility and lock controls belong to the shared segment, and the -// display sliders to the viewed image's segmentation. -// --------------------------------------------------------------------------- - const segments = () => useSegmentStore().segments; -const viewImage = async (id: string) => { - useViewStore().setDataForAllViews(id); - await nextTick(); -}; - const makeMask = (imageId: string, name: string) => { const segmentId = segments().mintSegment({ name }); const record = maskOn(imageId, segmentId); - return { id: segmentId, segmentId, maskId: record.id, record }; + return { segmentId, maskId: record.id }; }; const makeSegment = (name: string) => segments().mintSegment({ name }); -// The item list stands in for the real one so the per-row slot renders without -// Vuetify: rows carry their segment id, and the row buttons keep the icon names -// the list uses today. +// Slots expose row controls without mounting the Vuetify list. const ItemListStub = defineComponent({ name: 'EditableItemList', props: ['items', 'modelValue', 'createText'], @@ -127,6 +108,13 @@ const SliderStub = defineComponent({ />`, }); +const SaveDialogStub = defineComponent({ + name: 'SaveSegmentationDialog', + props: ['id'], + emits: ['done'], + template: '
    ', +}); + const globalOptions = { stubs: { VSlider: SliderStub, @@ -142,7 +130,7 @@ const globalOptions = { template: '
    ', }, - SaveSegmentationDialog: { props: ['id'], template: '
    ' }, + SaveSegmentationDialog: SaveDialogStub, VBtn: BtnStub, VIcon: IconStub, VSpacer: { template: '' }, @@ -221,7 +209,7 @@ describe('flat segment list', () => { setActivePinia(createPinia()); await seatSpecImage('img-1'); await seatSpecImage('img-2'); - await viewImage('img-1'); + await showImage('img-1'); }); it('lists the registry in creation order, keyed by segment id', async () => { @@ -231,7 +219,7 @@ describe('flat segment list', () => { const wrapper = mountList(); await nextTick(); - expect(rowIds(wrapper)).toEqual([first.id, second.id]); + expect(rowIds(wrapper)).toEqual([first.segmentId, second.segmentId]); expect( itemList(wrapper) .props('items') @@ -241,12 +229,14 @@ describe('flat segment list', () => { it('lists a segment that has no voxels yet', async () => { const unbound = makeMask('img-1', 'Tumor'); - expect(unbound.record.representations.labelmap).toBeUndefined(); + expect( + store().getMask(unbound.maskId).representations.labelmap + ).toBeUndefined(); const wrapper = mountList(); await nextTick(); - expect(rowIds(wrapper)).toEqual([unbound.id]); + expect(rowIds(wrapper)).toEqual([unbound.segmentId]); }); it('offers a segment with no mask on this image', async () => { @@ -256,7 +246,7 @@ describe('flat segment list', () => { const wrapper = mountList(); await nextTick(); - expect(rowIds(wrapper)).toEqual([onTwo.id, everywhere]); + expect(rowIds(wrapper)).toEqual([onTwo.segmentId, everywhere]); }); it('keeps the same rows when the viewed image changes', async () => { @@ -265,11 +255,11 @@ describe('flat segment list', () => { const wrapper = mountList(); await nextTick(); - expect(rowIds(wrapper)).toEqual([onOne.id, onTwo.id]); + expect(rowIds(wrapper)).toEqual([onOne.segmentId, onTwo.segmentId]); - await viewImage('img-2'); + await showImage('img-2'); - expect(rowIds(wrapper)).toEqual([onOne.id, onTwo.id]); + expect(rowIds(wrapper)).toEqual([onOne.segmentId, onTwo.segmentId]); }); }); @@ -292,7 +282,7 @@ describe('flat segment list with no viewed image', () => { const wrapper = mountList(); await nextTick(); - await viewImage('img-1'); + await showImage('img-1'); expect(wrapper.find('[data-testid="segment-list"]').exists()).toBe(true); expect(wrapper.text()).not.toContain('No selected image'); @@ -304,7 +294,7 @@ describe('flat segment list selection', () => { setActivePinia(createPinia()); await seatSpecImage('img-1'); await seatSpecImage('img-2'); - await viewImage('img-1'); + await showImage('img-1'); }); it('marks the selected segment as the selected row', async () => { @@ -315,7 +305,7 @@ describe('flat segment list selection', () => { const wrapper = mountList(); await nextTick(); - expect(itemList(wrapper).props('modelValue')).toBe(second.segmentId); + expect(selectedRow(wrapper)).toBe(second.segmentId); }); it('marks the first row selected while no segment has been chosen', async () => { @@ -325,7 +315,7 @@ describe('flat segment list selection', () => { const wrapper = mountList(); await nextTick(); - expect(itemList(wrapper).props('modelValue')).toBe(first.segmentId); + expect(selectedRow(wrapper)).toBe(first.segmentId); }); it('keeps the selected row when the list picks nothing', async () => { @@ -338,7 +328,7 @@ describe('flat segment list selection', () => { itemList(wrapper).vm.$emit('update:model-value', null); await nextTick(); - expect(itemList(wrapper).props('modelValue')).toBe(second.segmentId); + expect(selectedRow(wrapper)).toBe(second.segmentId); }); it('selects a segment by id when a row is picked', async () => { @@ -357,12 +347,12 @@ describe('flat segment list selection', () => { it('keeps the selected row on an image the segment has no mask on', async () => { const onOne = makeMask('img-1', 'Tumor'); segments().selectSegment(onOne.segmentId); - await viewImage('img-2'); + await showImage('img-2'); const wrapper = mountList(); await nextTick(); - expect(itemList(wrapper).props('modelValue')).toBe(onOne.segmentId); + expect(selectedRow(wrapper)).toBe(onOne.segmentId); }); }); @@ -370,7 +360,7 @@ describe('flat segment list row creation', () => { beforeEach(async () => { setActivePinia(createPinia()); await seatSpecImage('img-1'); - await viewImage('img-1'); + await showImage('img-1'); }); it('adds a row', async () => { @@ -402,7 +392,7 @@ describe('flat segment list row creation', () => { itemList(wrapper).vm.$emit('create'); await nextTick(); - await viewImage('img-2'); + await showImage('img-2'); expect(rowIds(wrapper)).toHaveLength(2); }); @@ -412,49 +402,39 @@ describe('flat segment list row actions', () => { beforeEach(async () => { setActivePinia(createPinia()); await seatSpecImage('img-1'); - await viewImage('img-1'); + await showImage('img-1'); }); - it('toggles one segment’s visibility by id', async () => { + it("toggles one segment's visibility by id", async () => { const first = makeMask('img-1', 'Tumor'); const second = makeMask('img-1', 'Node'); const wrapper = mountList(); await nextTick(); - await rowButton(wrapper, second.id, ['mdi-eye', 'mdi-eye-off']).trigger( - 'click' - ); + await rowButton(wrapper, second.segmentId, [ + 'mdi-eye', + 'mdi-eye-off', + ]).trigger('click'); - expect(eyeOf(wrapper, second.id)).toBe('mdi-eye-off'); - expect(eyeOf(wrapper, first.id)).toBe('mdi-eye'); + expect(eyeOf(wrapper, second.segmentId)).toBe('mdi-eye-off'); + expect(eyeOf(wrapper, first.segmentId)).toBe('mdi-eye'); }); - it('toggles one segment’s lock by id', async () => { + it("toggles one segment's lock by id", async () => { const first = makeMask('img-1', 'Tumor'); const second = makeMask('img-1', 'Node'); const wrapper = mountList(); await nextTick(); - await rowButton(wrapper, second.id, ['mdi-lock', 'mdi-lock-open']).trigger( - 'click' - ); + await rowButton(wrapper, second.segmentId, [ + 'mdi-lock', + 'mdi-lock-open', + ]).trigger('click'); - expect(lockOf(wrapper, second.id)).toBe('mdi-lock'); - expect(lockOf(wrapper, first.id)).toBe('mdi-lock-open'); + expect(lockOf(wrapper, second.segmentId)).toBe('mdi-lock'); + expect(lockOf(wrapper, first.segmentId)).toBe('mdi-lock-open'); }); - // The tooltip is the only place the panel can say what locking does, and the - // shared stub drops its content, so this mounts one that renders it. - const mountWithTooltips = () => - mount(SegmentList, { - global: { - stubs: { - ...globalOptions.stubs, - VTooltip: { template: '' }, - }, - }, - }); - const lockTooltip = (wrapper: VueWrapper, id: string) => { const button = wrapper .find(`[data-id="${id}"]`) @@ -470,17 +450,19 @@ describe('flat segment list row actions', () => { it('says on the lock that other segments paint around it', async () => { const segment = makeMask('img-1', 'Tumor'); - const wrapper = mountWithTooltips(); + const wrapper = mountListWithTooltips(); await nextTick(); - expect(lockTooltip(wrapper, segment.id)).toMatch(/^Lock\b/); - expect(lockTooltip(wrapper, segment.id)).toMatch(/goes around it/i); + expect(lockTooltip(wrapper, segment.segmentId)).toMatch(/^Lock\b/); + expect(lockTooltip(wrapper, segment.segmentId)).toMatch(/goes around it/i); lockSegment(segment.maskId, true); await nextTick(); - expect(lockTooltip(wrapper, segment.id)).toMatch(/^Unlock\b/); - expect(lockTooltip(wrapper, segment.id)).toMatch(/takes its voxels/i); + expect(lockTooltip(wrapper, segment.segmentId)).toMatch(/^Unlock\b/); + expect(lockTooltip(wrapper, segment.segmentId)).toMatch( + /replaces its voxels/i + ); }); it('deletes one segment by id', async () => { @@ -489,10 +471,10 @@ describe('flat segment list row actions', () => { const wrapper = mountList(); await nextTick(); - await rowButton(wrapper, first.id, ['mdi-delete']).trigger('click'); + await rowButton(wrapper, first.segmentId, ['mdi-delete']).trigger('click'); await nextTick(); - expect(rowIds(wrapper)).toEqual([second.id]); + expect(rowIds(wrapper)).toEqual([second.segmentId]); }); it('offers visibility and lock on every row, mask here or not', async () => { @@ -503,7 +485,7 @@ describe('flat segment list row actions', () => { // Both describe the segment, so they hold on every image and are offered on // a row this image has painted nothing for. - [withMask.id, withoutMask].forEach((id) => { + [withMask.segmentId, withoutMask].forEach((id) => { expect(rowButton(wrapper, id, ['mdi-eye', 'mdi-eye-off']).exists()).toBe( true ); @@ -559,11 +541,11 @@ describe('flat segment list row actions', () => { .find('[data-testid="toggle-segments-visible-button"]') .trigger('click'); - [first.id, second.id, elsewhere.id].forEach((id) => + [first.segmentId, second.segmentId, elsewhere.segmentId].forEach((id) => expect(eyeOf(wrapper, id)).toBe('mdi-eye-off') ); - await viewImage('img-2'); - expect(eyeOf(wrapper, elsewhere.id)).toBe('mdi-eye-off'); + await showImage('img-2'); + expect(eyeOf(wrapper, elsewhere.segmentId)).toBe('mdi-eye-off'); }); }); @@ -571,7 +553,7 @@ describe('flat segment list row editing', () => { beforeEach(async () => { setActivePinia(createPinia()); await seatSpecImage('img-1'); - await viewImage('img-1'); + await showImage('img-1'); }); const reopenEditor = async (wrapper: VueWrapper, id: string) => { @@ -586,32 +568,32 @@ describe('flat segment list row editing', () => { return wrapper; }; - it('renames the row’s segment by id, keeping that id', async () => { + it("renames the row's segment by id, keeping that id", async () => { makeMask('img-1', 'Tumor'); const second = makeMask('img-1', 'Node'); - const wrapper = await openEditor(second.id); + const wrapper = await openEditor(second.segmentId); editor(wrapper).vm.$emit('update:name', 'Lesion'); editor(wrapper).vm.$emit('done'); await nextTick(); - expect(rowShown(wrapper, second.id).name).toBe('Lesion'); + expect(rowShown(wrapper, second.segmentId).name).toBe('Lesion'); }); - it('recolors the row’s segment by id', async () => { + it("recolors the row's segment by id", async () => { const segment = makeMask('img-1', 'Tumor'); - const wrapper = await openEditor(segment.id); + const wrapper = await openEditor(segment.segmentId); editor(wrapper).vm.$emit('update:color', '#0000ff'); editor(wrapper).vm.$emit('done'); await nextTick(); - expect(rowShown(wrapper, segment.id).color).toBe('#0000ff'); + expect(rowShown(wrapper, segment.segmentId).color).toBe('#0000ff'); }); - it('edits the segment’s fill opacity, outline opacity and stroke width', async () => { + it("edits the segment's fill opacity, outline opacity and stroke width", async () => { const segment = makeMask('img-1', 'Tumor'); - const wrapper = await openEditor(segment.id); + const wrapper = await openEditor(segment.segmentId); expect(editor(wrapper).props('fillOpacity')).toBe(1); expect(editor(wrapper).props('outlineOpacity')).toBe(1); @@ -621,7 +603,7 @@ describe('flat segment list row editing', () => { editor(wrapper).vm.$emit('update:strokeWidth', 3); editor(wrapper).vm.$emit('done'); await nextTick(); - await reopenEditor(wrapper, segment.id); + await reopenEditor(wrapper, segment.segmentId); expect(editor(wrapper).props()).toMatchObject({ fillOpacity: 0.5, @@ -632,32 +614,32 @@ describe('flat segment list row editing', () => { it('discards the edit when the dialog is cancelled', async () => { const segment = makeMask('img-1', 'Tumor'); - const wrapper = await openEditor(segment.id); + const wrapper = await openEditor(segment.segmentId); editor(wrapper).vm.$emit('update:name', 'Lesion'); editor(wrapper).vm.$emit('update:fillOpacity', 0.5); editor(wrapper).vm.$emit('cancel'); await nextTick(); - await reopenEditor(wrapper, segment.id); + await reopenEditor(wrapper, segment.segmentId); - expect(rowShown(wrapper, segment.id).name).toBe('Tumor'); + expect(rowShown(wrapper, segment.segmentId).name).toBe('Tumor'); expect(editor(wrapper).props()).toMatchObject({ name: 'Tumor', fillOpacity: 1, }); }); - it('offers the other rows’ names as taken', async () => { + it("offers the other rows' names as taken", async () => { makeMask('img-1', 'Tumor'); const second = makeMask('img-1', 'Node'); - const wrapper = await openEditor(second.id); + const wrapper = await openEditor(second.segmentId); expect([...editor(wrapper).props('invalidNames')]).toEqual(['Tumor']); }); it('passes the unedited name to the editor', async () => { const segment = makeMask('img-1', 'Tumor'); - const wrapper = await openEditor(segment.id); + const wrapper = await openEditor(segment.segmentId); expect(editor(wrapper).props('original')).toBe('Tumor'); }); @@ -668,7 +650,7 @@ describe('flat segment list on a cine image', () => { beforeEach(async () => { setActivePinia(createPinia()); seatCineImage('cine-1'); - await viewImage('cine-1'); + await showImage('cine-1'); }); it('creates and edits distinct measurement segments', async () => { @@ -737,7 +719,7 @@ describe('flat segment list on a cine image', () => { ); }); - it('keeps an empty segment’s reveal disabled', async () => { + it("keeps an empty segment's reveal disabled", async () => { const segmentId = segments().addSegment(); const wrapper = mountList(); expect( @@ -750,7 +732,7 @@ describe('segmentation display section', () => { beforeEach(async () => { setActivePinia(createPinia()); await seatSpecImage('img-1'); - await viewImage('img-1'); + await showImage('img-1'); }); const slider = (wrapper: VueWrapper, label: string) => { @@ -785,14 +767,16 @@ describe('segmentation display section', () => { const wrapper = mountList(); await nextTick(); - expect(slider(wrapper, 'Fill Opacity').attributes('data-value')).toBe( - String(DEFAULT_SEGMENTATION_FILL_OPACITY) - ); - expect(slider(wrapper, 'Outline Opacity').attributes('data-value')).toBe( - '1' - ); - expect(slider(wrapper, 'Outline Thickness').attributes('data-value')).toBe( - '2' + expect( + ['Fill Opacity', 'Outline Opacity', 'Outline Thickness'].map((label) => + slider(wrapper, label).attributes('data-value') + ) + ).toEqual( + [ + DEFAULT_SEGMENTATION_DISPLAY.fillOpacity, + DEFAULT_SEGMENTATION_DISPLAY.outlineOpacity, + DEFAULT_SEGMENTATION_DISPLAY.outlineThickness, + ].map(String) ); }); @@ -806,7 +790,7 @@ describe('segmentation display section', () => { ); }); - it('seats each control at the segmentation’s current value', async () => { + it("seats each control at the segmentation's current value", async () => { const segmentation = store().ensureSegmentationForImage('img-1'); store().createMask( segmentation.id, @@ -837,7 +821,7 @@ describe('segmentation display section', () => { ['Outline Opacity', 0.5], ['Outline Thickness', 4], ] as const)( - 'writes %s onto the viewed image’s segmentation', + "writes %s onto the viewed image's segmentation", async (label, value) => { const segmentation = store().ensureSegmentationForImage('img-1'); store().createMask( @@ -855,7 +839,7 @@ describe('segmentation display section', () => { } ); - it('writes only the viewed image’s segmentation', async () => { + it("writes only the viewed image's segmentation", async () => { await seatSpecImage('img-2', 'MR'); const first = store().ensureSegmentationForImage('img-1'); store().createMask(first.id, segments().mintSegment({ name: 'Tumor' })); @@ -865,12 +849,12 @@ describe('segmentation display section', () => { await nextTick(); await setSlider(wrapper, 'Fill Opacity', 0.25); - await viewImage('img-2'); + await showImage('img-2'); expect(slider(wrapper, 'Fill Opacity').attributes('data-value')).toBe( - String(DEFAULT_SEGMENTATION_FILL_OPACITY) + String(DEFAULT_SEGMENTATION_DISPLAY.fillOpacity) ); - await viewImage('img-1'); + await showImage('img-1'); expect(slider(wrapper, 'Fill Opacity').attributes('data-value')).toBe( '0.25' ); @@ -885,7 +869,7 @@ describe('Reveal Slice on a segment row', () => { beforeEach(async () => { setActivePinia(createPinia()); await seatImage('img-1', { name: 'CT', dimensions: REVEAL_DIMENSIONS }); - await viewImage('img-1'); + await showImage('img-1'); }); // Padded like a stroke, so the allocation is wider than what is marked. @@ -904,7 +888,7 @@ describe('Reveal Slice on a segment row', () => { await nextTick(); expect( - revealButton(wrapper, segment.id).attributes('disabled') + revealButton(wrapper, segment.segmentId).attributes('disabled') ).toBeDefined(); }); @@ -913,7 +897,8 @@ describe('Reveal Slice on a segment row', () => { const wrapper = mountListWithTooltips(); const list = () => wrapper.find('[data-testid="segment-list"]'); const reason = () => - revealButton(wrapper, segment.id).element.parentElement?.textContent; + revealButton(wrapper, segment.segmentId).element.parentElement + ?.textContent; useLoadDataStore().startLoading(); await nextTick(); @@ -932,7 +917,8 @@ describe('Reveal Slice on a segment row', () => { await nextTick(); expect( - revealButton(wrapper, segment.id).element.parentElement?.textContent + revealButton(wrapper, segment.segmentId).element.parentElement + ?.textContent ).toMatch(/nothing on this image/i); }); @@ -944,7 +930,7 @@ describe('Reveal Slice on a segment row', () => { const wrapper = mountList({ reveal }); await nextTick(); - await revealButton(wrapper, segment.id).trigger('click'); + await revealButton(wrapper, segment.segmentId).trigger('click'); expect(reveal.mock.calls).toEqual([ [ @@ -958,18 +944,44 @@ describe('Reveal Slice on a segment row', () => { ]); }); + it('offers reveal for a segment that only has annotations, at their slices', async () => { + const shaped = makeSegment('Shaped'); + useRulerStore().addTool({ + imageID: 'img-1', + segmentId: shaped, + slice: 3, + frameOfReference: AXIAL_FRAME_OF_REFERENCE, + }); + const reveal = vi.fn(); + const wrapper = mountList({ reveal }); + await nextTick(); + + await revealButton(wrapper, shaped).trigger('click'); + + expect(reveal.mock.calls).toEqual([ + [ + 'img-1', + { + paintedSlicesByIJK: undefined, + slicesByAxis: { Axial: [3] }, + frames: [], + }, + ], + ]); + }); + it('enables reveal when painting creates storage after the list mounts', async () => { const segment = makeMask('img-1', 'Tumor'); const wrapper = mountList(); await nextTick(); expect( - revealButton(wrapper, segment.id).attributes('disabled') + revealButton(wrapper, segment.segmentId).attributes('disabled') ).toBeDefined(); paintVoxel(segment.maskId, [1, 1, 1]); await nextTick(); expect( - revealButton(wrapper, segment.id).attributes('disabled') + revealButton(wrapper, segment.segmentId).attributes('disabled') ).toBeUndefined(); }); @@ -983,7 +995,7 @@ describe('Reveal Slice on a segment row', () => { const wrapper = mountList({ reveal }); await nextTick(); - await revealButton(wrapper, segment.id).trigger('click'); + await revealButton(wrapper, segment.segmentId).trigger('click'); expect(reveal.mock.calls).toEqual([ [ @@ -994,112 +1006,12 @@ describe('Reveal Slice on a segment row', () => { }); }); -const ANNOTATION_STORES = [ - ['ruler', useRulerStore], - ['rectangle', useRectangleStore], - ['polygon', usePolygonStore], -] as const; - -describe.each(ANNOTATION_STORES)( - 'shared segment visibility for a %s', - (_name, useStore) => { - beforeEach(async () => { - setActivePinia(createPinia()); - await seatSpecImage('img-1'); - await viewImage('img-1'); - }); - - it('composes row and global visibility with independent child flags across images and cine frames', async () => { - const tools = useStore(); - const segmentId = segments().addSegment(); - const addShape = (imageID: string, hidden = false, frame?: number) => - tools.addTool({ - imageID, - segmentId, - slice: 0, - frameOfReference: AXIAL_FRAME_OF_REFERENCE, - hidden, - frame, - }); - const shown = addShape('img-1'); - addShape('img-1', true); - seatCineImage('cine-1'); - const cineFirst = addShape('cine-1', false, 0); - const cineSecond = addShape('cine-1', false, 1); - const viewFrame = ref(); - const rendered = useCurrentTools(tools, ref('Axial'), ref([]), viewFrame); - const ids = () => rendered.value.map((tool) => tool.id); - const wrapper = mountList(); - expect(ids()).toEqual([shown]); - - await rowButton(wrapper, segmentId, ['mdi-eye', 'mdi-eye-off']).trigger( - 'click' - ); - expect(ids()).toEqual([]); - await viewImage('cine-1'); - viewFrame.value = 0; - expect(ids()).toEqual([]); - await wrapper - .get('[data-testid="toggle-segments-visible-button"]') - .trigger('click'); - expect(ids()).toEqual([cineFirst]); - viewFrame.value = 1; - expect(ids()).toEqual([cineSecond]); - await wrapper - .get('[data-testid="toggle-segments-visible-button"]') - .trigger('click'); - expect(ids()).toEqual([]); - await rowButton(wrapper, segmentId, ['mdi-eye', 'mdi-eye-off']).trigger( - 'click' - ); - await viewImage('img-1'); - viewFrame.value = undefined; - expect(ids()).toEqual([shown]); - }); - - it('keeps the active placement alive through hiding, committing and starting again', () => { - const tools = useStore(); - const segmentId = segments().addSegment(); - const metadata = ref({ - imageID: 'img-1', - segmentId, - slice: 0, - frameOfReference: AXIAL_FRAME_OF_REFERENCE, - }); - const placing = usePlacingAnnotationTool(tools, metadata); - placing.add(); - const first = placing.id.value!; - const whitelist = ref([first]); - const rendered = useCurrentTools(tools, ref('Axial'), whitelist); - const otherViewStub = tools.addTool({ ...metadata.value, placing: true }); - placing.beginPlacement(); - segments().updateSegment(segmentId, { visible: false }); - expect(rendered.value.map((tool) => tool.id)).toEqual([first]); - expect(tools.toolByID[otherViewStub]).toBeDefined(); - - placing.commit(); - expect(rendered.value).toEqual([]); - expect(tools.toolByID[first].placing).toBe(false); - placing.add(); - whitelist.value = [placing.id.value!]; - expect(rendered.value.map((tool) => tool.id)).toEqual([placing.id.value]); - segments().updateSegment(segmentId, { visible: true }); - expect(rendered.value.map((tool) => tool.id)).toEqual([ - first, - placing.id.value, - ]); - placing.remove(); - expect(rendered.value.map((tool) => tool.id)).toEqual([first]); - }); - } -); - describe('locked segment editor routes', () => { beforeEach(async () => { setActivePinia(createPinia()); await seatSpecImage('img-1'); await seatSpecImage('img-2'); - await viewImage('img-1'); + await showImage('img-1'); }); const protectedContent = () => { @@ -1119,7 +1031,7 @@ describe('locked segment editor routes', () => { // The row keeps its name and still reveals what it holds on both images. const expectPreserved = async (wrapper: VueWrapper, segmentId: string) => { for (const imageID of ['img-2', 'img-1']) { - await viewImage(imageID); + await showImage(imageID); expect(rowShown(wrapper, segmentId).name).toBe('Tumor'); expect( revealButton(wrapper, segmentId).attributes('disabled') @@ -1174,24 +1086,14 @@ describe('locked segment editor routes', () => { ); }); -// --------------------------------------------------------------------------- -// Deleting a segment cascades to its mask on every image and to every -// annotation naming it, none of which need be visible here, and there is no -// undo. No dialog asks first, as everywhere else in the app, so the list says -// afterwards what went, the way removeSelectedTools does. -// --------------------------------------------------------------------------- - describe('deleting a segment says what went with it', () => { beforeEach(async () => { setActivePinia(createPinia()); await seatSpecImage('img-1'); await seatSpecImage('img-2'); - await viewImage('img-1'); + await showImage('img-1'); }); - const titles = () => - useMessageStore().messages.map((message) => message.title); - const spreadSegment = (imageIDs: string[], name = 'Tumor') => { const segmentId = segments().addSegment({ name }); const rulers = useRulerStore(); @@ -1220,7 +1122,9 @@ describe('deleting a segment says what went with it', () => { await deleteRow(wrapper, segmentId); - expect(titles()).toEqual(['Deleted 2 masks on 2 images and 2 annotations']); + expect(messageTitles()).toEqual([ + 'Deleted 2 masks on 2 images and 2 annotations', + ]); }); it('says one of each in the singular', async () => { @@ -1230,7 +1134,9 @@ describe('deleting a segment says what went with it', () => { await deleteRow(wrapper, segmentId); - expect(titles()).toEqual(['Deleted 1 mask on 1 image and 1 annotation']); + expect(messageTitles()).toEqual([ + 'Deleted 1 mask on 1 image and 1 annotation', + ]); }); it('names only what the segment had', async () => { @@ -1246,10 +1152,10 @@ describe('deleting a segment says what went with it', () => { const wrapper = mountList(); await nextTick(); - await deleteRow(wrapper, painted.id); + await deleteRow(wrapper, painted.segmentId); await deleteRow(wrapper, shaped); - expect(titles()).toEqual([ + expect(messageTitles()).toEqual([ 'Deleted 1 mask on 1 image', 'Deleted 1 annotation', ]); @@ -1260,15 +1166,15 @@ describe('deleting a segment says what went with it', () => { // drops the record, but there was nothing on that image to lose. it('counts no mask on an image the segment was only resolved on', async () => { const recorded = makeMask('img-1', 'Resolved'); - const allocated = maskOn('img-2', recorded.id); + const allocated = maskOn('img-2', recorded.segmentId); store().maskVoxels(allocated.id).materialize(); const wrapper = mountList(); await nextTick(); - await deleteRow(wrapper, recorded.id); + await deleteRow(wrapper, recorded.segmentId); expect(rowIds(wrapper)).toEqual([]); - expect(titles()).toEqual([]); + expect(messageTitles()).toEqual([]); }); it('stays quiet when the segment held nothing', async () => { @@ -1279,7 +1185,7 @@ describe('deleting a segment says what went with it', () => { await deleteRow(wrapper, empty); expect(rowIds(wrapper)).toEqual([]); - expect(titles()).toEqual([]); + expect(messageTitles()).toEqual([]); }); it('reports the same cascade when the editor deletes', async () => { @@ -1294,22 +1200,17 @@ describe('deleting a segment says what went with it', () => { await nextTick(); expect(rowIds(wrapper)).toEqual([]); - expect(titles()).toEqual(['Deleted 2 masks on 2 images and 2 annotations']); + expect(messageTitles()).toEqual([ + 'Deleted 2 masks on 2 images and 2 annotations', + ]); }); }); -// --------------------------------------------------------------------------- -// A row is rebuilt from every annotation in the scene, and dragging one ruler -// is a store write per pointer move. The list hands back the row object it -// built last time when nothing the row shows has changed, so the item list's -// per-row memo holds and only the rows that changed re-render. -// --------------------------------------------------------------------------- - describe('segment row identity', () => { beforeEach(async () => { setActivePinia(createPinia()); await seatSpecImage('img-1'); - await viewImage('img-1'); + await showImage('img-1'); }); const rulerOn = (segmentId: string, slice = 0) => @@ -1337,7 +1238,10 @@ describe('segment row identity', () => { const after = rowsOf(wrapper); expect(after[0]).toBe(before[0]); expect(after[1]).toBe(before[1]); - expect(after.map((row) => row.id)).toEqual([first.id, second.id]); + expect(after.map((row) => row.id)).toEqual([ + first.segmentId, + second.segmentId, + ]); }); it('replaces only the row whose annotation count changed', async () => { @@ -1369,15 +1273,106 @@ describe('segment row identity', () => { expect(after[0]).toBe(before[0]); expect(after[1]).not.toBe(before[1]); }); +}); - it('still offers reveal for a segment that only has annotations', async () => { - const shaped = makeSegment('Shaped'); - rulerOn(shaped); - const wrapper = mountList(); +const saveButton = (wrapper: VueWrapper) => + wrapper.find('[data-testid="save-segments-button"]'); + +const saveDialog = (wrapper: VueWrapper) => + wrapper.findComponent(SaveDialogStub); + +const paintMask = (imageId: string, name: string) => { + const segmentation = store().ensureSegmentationForImage(imageId); + const mask = store().createMask(segmentation.id, mintSegment({ name })); + seedVoxel(mask.id, [1, 1, 0]); + return segmentation; +}; + +describe('saving from the flat segment panel', () => { + beforeEach(async () => { + setActivePinia(createPinia()); + await seatSpecImage('img-1'); + await seatSpecImage('img-2', 'MR'); + await showImage('img-1'); + }); + + it('offers the save affordance disabled, saying why, until something is painted', async () => { + const wrapper = mountListWithTooltips(); + await nextTick(); + + expect(saveButton(wrapper).exists()).toBe(true); + expect(saveButton(wrapper).attributes('disabled')).toBeDefined(); + expect(wrapper.text()).toContain('Nothing is painted on this image yet'); + }); + + it('offers one save affordance once the viewed image has segments', async () => { + paintMask('img-1', 'Tumor'); + const wrapper = mountListWithTooltips(); await nextTick(); expect( - revealButton(wrapper, shaped).attributes('disabled') - ).toBeUndefined(); + wrapper.findAll('[data-testid="save-segments-button"]') + ).toHaveLength(1); + }); + + // A segment resolved as an edit target mints a record, and allocating its + // storage does not put a voxel in it: neither is anything to write out. + it('keeps the save affordance disabled for masks that hold nothing', async () => { + const segmentation = store().ensureSegmentationForImage('img-1'); + store().createMask(segmentation.id, mintSegment({ name: 'Resolved' })); + const allocated = store().createMask( + segmentation.id, + mintSegment({ name: 'Allocated' }) + ); + store().maskVoxels(allocated.id).materialize(); + const wrapper = mountListWithTooltips(); + await nextTick(); + + expect(saveButton(wrapper).attributes('disabled')).toBeDefined(); + expect(wrapper.text()).toContain('Nothing is painted on this image yet'); + }); + + it('opens the save dialog on the viewed image segmentation', async () => { + const segmentation = paintMask('img-1', 'Tumor'); + const wrapper = mountListWithTooltips(); + await nextTick(); + + expect(saveDialog(wrapper).exists()).toBe(false); + expect(saveButton(wrapper).exists()).toBe(true); + + await saveButton(wrapper).trigger('click'); + await nextTick(); + + expect(saveDialog(wrapper).props('id')).toBe(segmentation.id); + }); + + // The create affordance names the row it adds, and it reads as an expression + // rather than a literal attribute, so the source scan below cannot see it. + it('names what the create affordance adds without a storage word', async () => { + const wrapper = mountListWithTooltips(); + await nextTick(); + + expect(wrapper.findComponent(ItemListStub).props('createText')).toBe( + 'New segment' + ); + }); + + it('follows the viewed image rather than the selected segment', async () => { + const first = paintMask('img-1', 'Tumor'); + const second = store().ensureSegmentationForImage('img-2'); + const onSecond = store().createMask( + second.id, + mintSegment({ name: 'Node' }) + ); + // The selected segment has its mask on the image that is NOT being viewed. + useSegmentStore().segments.selectSegment(onSecond.segmentId); + const wrapper = mountListWithTooltips(); + await nextTick(); + + expect(saveButton(wrapper).exists()).toBe(true); + await saveButton(wrapper).trigger('click'); + await nextTick(); + + expect(saveDialog(wrapper).props('id')).toBe(first.id); }); }); diff --git a/src/segmentation/components/__tests__/segmentPanelSurface.spec.ts b/src/segmentation/components/__tests__/segmentPanelSurface.spec.ts index 898f1d351..c066c1315 100644 --- a/src/segmentation/components/__tests__/segmentPanelSurface.spec.ts +++ b/src/segmentation/components/__tests__/segmentPanelSurface.spec.ts @@ -1,213 +1,7 @@ -import { beforeEach, describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import { repoRoot } from '@/src/__tests__/sourceAudit'; -import { setActivePinia, createPinia } from 'pinia'; -import { defineComponent, nextTick } from 'vue'; -import { mount, VueWrapper } from '@vue/test-utils'; - -import SegmentList from '@/src/segmentation/components/SegmentList.vue'; -import { - seatSpecImage, - store, - mintSegment, - seedVoxel, -} from '@/src/segmentation/__tests__/segmentMaskFixtures'; -import { useSegmentStore } from '@/src/segmentation/segments'; -import { useViewStore } from '@/src/store/views'; - -// --------------------------------------------------------------------------- -// The segmentation panel is one flat list scoped to the viewed image. Saving -// that image's segmentation to a file lives on the list, and is absent when the -// image has nothing to save. -// -// No panel says "segment group", "labelmap", "label value" or "layer" in -// user-visible text. Identifiers are out of scope, so the scan reads text nodes -// and the static attributes a user actually reads, never template expressions -// or component names. -// --------------------------------------------------------------------------- - -const viewImage = async (id: string) => { - useViewStore().setDataForAllViews(id); - await nextTick(); -}; - -const ItemListStub = defineComponent({ - name: 'EditableItemList', - props: ['items', 'modelValue', 'createText'], - emits: ['update:model-value', 'create'], - template: ` -
    -
    - - -
    -
    - `, -}); - -const BtnStub = defineComponent({ - name: 'VBtn', - props: ['icon', 'disabled'], - template: ``, -}); - -const SaveDialogStub = defineComponent({ - name: 'SaveSegmentationDialog', - props: ['id'], - emits: ['done'], - template: `
    `, -}); - -// Either dialog host works: the slot renders unless the host is explicitly -// closed, so a `v-model`-gated host and an inner `v-if` both read correctly. -const DialogHostStub = (name: string) => - defineComponent({ - name, - props: ['modelValue', 'maxWidth'], - emits: ['update:modelValue'], - template: `
    `, - }); - -const globalOptions = { - stubs: { - EditableItemList: ItemListStub, - SegmentEditor: { template: '
    ' }, - SaveSegmentationDialog: SaveDialogStub, - IsolatedDialog: DialogHostStub('IsolatedDialog'), - CloseableDialog: DialogHostStub('CloseableDialog'), - VDialog: DialogHostStub('VDialog'), - VBtn: BtnStub, - VIcon: { template: '' }, - VTooltip: { template: '' }, - VMenu: { - template: '
    ', - }, - VList: { template: '
    ' }, - VListItem: { template: '
    ' }, - VSpacer: { template: '' }, - VSlider: { props: ['label', 'modelValue'], template: '' }, - VExpansionPanels: { template: '
    ' }, - VExpansionPanel: { template: '
    ' }, - VExpansionPanelTitle: { template: '' }, - VExpansionPanelText: { template: '
    ' }, - VDivider: { template: '
    ' }, - }, -}; - -const mountList = () => - mount(SegmentList, { - props: { - registry: useSegmentStore().segments, - noun: 'segment', - masked: true, - }, - global: globalOptions, - }); - -const saveButton = (wrapper: VueWrapper) => - wrapper.find('[data-testid="save-segments-button"]'); - -const saveDialog = (wrapper: VueWrapper) => - wrapper.findComponent(SaveDialogStub); - -const paintMask = (imageId: string, name: string) => { - const segmentation = store().ensureSegmentationForImage(imageId); - const mask = store().createMask(segmentation.id, mintSegment({ name })); - seedVoxel(mask.id, [1, 1, 0]); - return segmentation; -}; - -describe('saving from the flat segment panel', () => { - beforeEach(async () => { - setActivePinia(createPinia()); - await seatSpecImage('img-1'); - await seatSpecImage('img-2', 'MR'); - await viewImage('img-1'); - }); - - it('offers the save affordance disabled, saying why, until something is painted', async () => { - const wrapper = mountList(); - await nextTick(); - - expect(saveButton(wrapper).exists()).toBe(true); - expect(saveButton(wrapper).attributes('disabled')).toBeDefined(); - expect(wrapper.text()).toContain('Nothing is painted on this image yet'); - }); - - it('offers one save affordance once the viewed image has segments', async () => { - paintMask('img-1', 'Tumor'); - const wrapper = mountList(); - await nextTick(); - - expect( - wrapper.findAll('[data-testid="save-segments-button"]') - ).toHaveLength(1); - }); - - // A segment resolved as an edit target mints a record, and allocating its - // storage does not put a voxel in it: neither is anything to write out. - it('keeps the save affordance disabled for masks that hold nothing', async () => { - const segmentation = store().ensureSegmentationForImage('img-1'); - store().createMask(segmentation.id, mintSegment({ name: 'Resolved' })); - const allocated = store().createMask( - segmentation.id, - mintSegment({ name: 'Allocated' }) - ); - store().maskVoxels(allocated.id).materialize(); - const wrapper = mountList(); - await nextTick(); - - expect(saveButton(wrapper).attributes('disabled')).toBeDefined(); - expect(wrapper.text()).toContain('Nothing is painted on this image yet'); - }); - - it('opens the save dialog on the viewed image segmentation', async () => { - const segmentation = paintMask('img-1', 'Tumor'); - const wrapper = mountList(); - await nextTick(); - - expect(saveDialog(wrapper).exists()).toBe(false); - expect(saveButton(wrapper).exists()).toBe(true); - - await saveButton(wrapper).trigger('click'); - await nextTick(); - - expect(saveDialog(wrapper).props('id')).toBe(segmentation.id); - }); - - // The create affordance names the row it adds, and it reads as an expression - // rather than a literal attribute, so the source scan below cannot see it. - it('names what the create affordance adds without a storage word', async () => { - const wrapper = mountList(); - await nextTick(); - - expect(wrapper.findComponent(ItemListStub).props('createText')).toBe( - 'New segment' - ); - }); - - it('follows the viewed image rather than the selected segment', async () => { - const first = paintMask('img-1', 'Tumor'); - const second = store().ensureSegmentationForImage('img-2'); - const onSecond = store().createMask( - second.id, - mintSegment({ name: 'Node' }) - ); - // The selected segment has its mask on the image that is NOT being viewed. - useSegmentStore().segments.selectSegment(onSecond.segmentId); - const wrapper = mountList(); - await nextTick(); - - expect(saveButton(wrapper).exists()).toBe(true); - await saveButton(wrapper).trigger('click'); - await nextTick(); - - expect(saveDialog(wrapper).props('id')).toBe(first.id); - }); -}); - -// --- user-visible panel text --- // const exists = (rel: string) => fs.existsSync(path.resolve(repoRoot, rel)); const read = (rel: string) => @@ -287,21 +81,18 @@ const componentFiles = (dir: string): string[] => describe('panel language', () => { it('keeps the segmentation panel free of group and storage words', () => { - // These two must be present, so the scan is never vacuous; a renamed save - // dialog simply drops out of the list. - expect(exists('src/components/AnnotationsModule.vue')).toBe(true); - expect(exists('src/segmentation/components/SegmentList.vue')).toBe(true); + expect(SEGMENTATION_PANEL.filter((rel) => !exists(rel))).toEqual([]); const banned = /segment group|labelmap|label value|layer/i; - const hits = SEGMENTATION_PANEL.filter(exists).flatMap((rel) => + const hits = SEGMENTATION_PANEL.flatMap((rel) => bannedIn(rel, banned).map((line) => `${rel}: ${line}`) ); expect(hits).toEqual([]); }); - it('keeps the panel’s notification titles free of storage words', () => { - const files = SEGMENTATION_PANEL.filter(exists); + it("keeps the panel's notification titles free of storage words", () => { + const files = SEGMENTATION_PANEL; // The panel reports at least one failure to the user, so the scan reads // something rather than passing on an empty match set. const messages = files.flatMap((rel) => bannedMessagesIn(rel, /.*/)); diff --git a/src/segmentation/composables/__tests__/useSegmentShapes.spec.ts b/src/segmentation/composables/__tests__/useSegmentShapes.spec.ts index 74e7ea3fb..5a681709b 100644 --- a/src/segmentation/composables/__tests__/useSegmentShapes.spec.ts +++ b/src/segmentation/composables/__tests__/useSegmentShapes.spec.ts @@ -1,29 +1,24 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { createPinia, setActivePinia } from 'pinia'; -import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; import { useSegmentShapes } from '@/src/segmentation/composables/useSegmentShapes'; import { useSegmentStore } from '@/src/segmentation/segments'; -import { useImageCacheStore } from '@/src/store/image-cache'; -import { useViewStore } from '@/src/store/views'; +import { + seatImage, + viewImage, +} from '@/src/segmentation/__tests__/segmentMaskFixtures'; import { useRulerStore } from '@/src/store/tools/rulers'; import { useRectangleStore } from '@/src/store/tools/rectangles'; import { AXIAL_FRAME_OF_REFERENCE } from '@/src/utils/frameOfReference'; -const seat = (id: string) => - useImageCacheStore().addVTKImageData(vtkImageData.newInstance(), 'CT', { - id, - }); - describe('segment shapes', () => { - beforeEach(() => { + beforeEach(async () => { setActivePinia(createPinia()); - seat('img-1'); - seat('img-2'); - useViewStore().setDataForAllViews('img-1'); + await seatImage('img-2'); + await viewImage('img-1'); }); - it('groups the viewed image’s finished shapes under their segments', () => { + it("groups the viewed image's finished shapes under their segments", () => { const { segments } = useSegmentStore(); const tumor = segments.mintSegment({ name: 'Tumor' }); const node = segments.mintSegment({ name: 'Node' }); diff --git a/src/segmentation/editing/__tests__/rasterizeWithProcess.spec.ts b/src/segmentation/editing/__tests__/rasterizeWithProcess.spec.ts index 2d9da5deb..0ef658cfd 100644 --- a/src/segmentation/editing/__tests__/rasterizeWithProcess.spec.ts +++ b/src/segmentation/editing/__tests__/rasterizeWithProcess.spec.ts @@ -1,15 +1,13 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { createPinia, setActivePinia } from 'pinia'; -import { createApp, nextTick } from 'vue'; import type { Vector3 } from '@kitware/vtk.js/types'; -import { CorePiniaProviderPlugin } from '@/src/core/provider'; import { rasterizePolygon } from '@/src/segmentation/editing/rasterizePolygon'; import { addMask, extentOf, maskValueAt, - seatImage, + activateAppPinia, + viewImage, seedVoxel, store, type Index3, @@ -17,7 +15,6 @@ import { segmentOfMask, } from '@/src/segmentation/__tests__/segmentMaskFixtures'; import { usePaintProcessStore } from '@/src/segmentation/editing/paintProcess'; -import { useViewStore } from '@/src/store/views'; import type { Extent3D } from '@/src/segmentation/geometry'; import { SEGMENT_VALUE } from '@/src/segmentation/masks/labelValue'; @@ -47,12 +44,8 @@ function rasterize(maskId: string) { } async function setUpRasterizeView() { - const pinia = createPinia().use(CorePiniaProviderPlugin()); - createApp({}).use(pinia); - setActivePinia(pinia); - await seatImage('img-1', { dimensions: DIMENSIONS }); - useViewStore().setDataForAllViews('img-1'); - await nextTick(); + activateAppPinia(); + await viewImage('img-1', { dimensions: DIMENSIONS }); } function setUpOverlappingSegments(extent: Extent3D) { diff --git a/src/store/__tests__/annotationToolImageDelete.spec.ts b/src/store/__tests__/annotationToolImageDelete.spec.ts index 7f404768c..0e7051bce 100644 --- a/src/store/__tests__/annotationToolImageDelete.spec.ts +++ b/src/store/__tests__/annotationToolImageDelete.spec.ts @@ -11,19 +11,7 @@ import { useRectangleStore } from '@/src/store/tools/rectangles'; import type { Ruler } from '@/src/types/ruler'; import type { RequiredWithPartial } from '@/src/types'; -// --------------------------------------------------------------------------- -// Delete-base-then-save, tool half: removing an -// annotated dataset must remove its annotation tools too — an orphaned -// imageID serialized into the save manifest is exactly the backend's -// intentionally fail-closed 400 ('tool has unresolvable imageID'), turning a -// routine delete gesture into a permanently unsavable session. -// -// The mechanism is the same onImageDeleted subscription segmentGroups already -// uses for its labelmap cascade — so this spec also pins the composable -// itself: it must actually FIRE on image-cache deletion (a watch on the ref -// of the reactive index never triggers on a key delete; the composable -// watches the key SET). -// --------------------------------------------------------------------------- +// Serialized tools must never retain references to removed images. const seatImage = (id: string, name: string) => useImageCacheStore().addVTKImageData(vtkImageData.newInstance(), name, { diff --git a/src/store/__tests__/datasets-layers.spec.ts b/src/store/__tests__/datasets-layers.spec.ts index 7fac26bf0..653fd1168 100644 --- a/src/store/__tests__/datasets-layers.spec.ts +++ b/src/store/__tests__/datasets-layers.spec.ts @@ -13,7 +13,10 @@ vi.mock('@/src/io/resample/resample', () => ({ ensureSameSpace })); import { useLayersStore } from '@/src/store/datasets-layers'; import { useImageCacheStore } from '@/src/store/image-cache'; -import { useMessageStore } from '@/src/store/messages'; +import { + messageTitles, + mountMessageCenter, +} from '@/src/components/__tests__/messageDisplay'; import { ParentToLayers, type Manifest, @@ -76,7 +79,7 @@ describe('useLayersStore.addLayer return contract', () => { expect(id).toBeUndefined(); expect(store.getLayers('parent')).toHaveLength(0); expect(cached('parent::source')).toBe(false); - expect(useMessageStore().messages[0].options.details).toContain( + expect(mountMessageCenter().get('.details').text()).toContain( 'no overlap in physical space' ); }); @@ -89,7 +92,7 @@ describe('useLayersStore.addLayer return contract', () => { expect(id).toBeUndefined(); expect(store.getLayers('parent')).toHaveLength(0); - expect(useMessageStore().messages[0].options.details).toContain( + expect(mountMessageCenter().get('.details').text()).toContain( 'Image did not load' ); }); @@ -107,7 +110,7 @@ describe('useLayersStore.addLayer return contract', () => { expect(id).toBeUndefined(); expect(cached('parent::source')).toBe(false); - expect(useMessageStore().messages).toHaveLength(0); + expect(messageTitles()).toHaveLength(0); }); }); @@ -181,7 +184,7 @@ describe('useLayersStore.deserialize with an image that did not load', () => { await settle(); expect(Object.keys(store.parentToLayers)).toEqual([]); - expect(useMessageStore().messages).toHaveLength(0); + expect(messageTitles()).toHaveLength(0); // Before the guard this threw: the failed build left `parentToLayers` with // a key whose value was `undefined`, and serialize mapped over it. expect(resave()).toEqual([]); @@ -197,7 +200,7 @@ describe('useLayersStore.deserialize with an image that did not load', () => { await settle(); expect(store.getLayers('parent')).toHaveLength(0); - expect(useMessageStore().messages).toHaveLength(0); + expect(messageTitles()).toHaveLength(0); // Before the guard the parent's whole relationship was saved with an // `undefined` source key, which the save-time schema rejects outright. expect(ParentToLayers.safeParse(resave()).success).toBe(true); diff --git a/src/store/__tests__/image-stats.spec.ts b/src/store/__tests__/image-stats.spec.ts index 1a3515016..9cc3a170a 100644 --- a/src/store/__tests__/image-stats.spec.ts +++ b/src/store/__tests__/image-stats.spec.ts @@ -2,11 +2,13 @@ import { MessageChannel } from 'node:worker_threads'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createPinia, disposePinia, setActivePinia } from 'pinia'; import { nextTick } from 'vue'; +import { flushPromises } from '@vue/test-utils'; +import { defer } from '@/src/utils'; import * as Comlink from 'comlink'; import { histogram } from '@/src/utils/histogram'; import { useImageCacheStore } from '@/src/store/image-cache'; import { useImageStatsStore } from '@/src/store/image-stats'; -import { useMessageStore } from '@/src/store/messages'; +import { messageTitles } from '@/src/components/__tests__/messageDisplay'; import { seatImage } from '@/src/segmentation/__tests__/segmentMaskFixtures'; // Real Comlink messages and histogram results, with completion controlled at @@ -26,11 +28,14 @@ class HistogramEndpoint { finish!: (error?: Error) => void; - started = false; + started = defer(); + + terminated = defer(); terminate = vi.fn(() => { this.channel.port1.close(); this.channel.port2.close(); + this.terminated.resolve(); }); constructor() { @@ -40,7 +45,7 @@ class HistogramEndpoint { Comlink.expose( { histogram: async (...args: Parameters) => { - this.started = true; + this.started.resolve(); await completion; return histogram(...args); }, @@ -90,18 +95,22 @@ describe('image statistics worker ownership', () => { ), }); const worker = workers[workers.length - 1]; - await vi.waitFor(() => expect(worker.started).toBe(true)); + await worker.started.promise; return worker; } - async function expectRanges(id: string, offset = 0) { - await vi.waitFor(() => { - expect(useImageStatsStore().getAutoRangeValues(id)).toEqual({ - FullRange: [offset, offset + 511], - LowContrast: [offset + 5, offset + 507], - MediumContrast: [offset + 10, offset + 502], - HighContrast: [offset + 25, offset + 487], - }); + const finish = async (worker: HistogramEndpoint, error?: Error) => { + worker.finish(error); + await worker.terminated.promise; + await flushPromises(); + }; + + function expectRanges(id: string, offset = 0) { + expect(useImageStatsStore().getAutoRangeValues(id)).toEqual({ + FullRange: [offset, offset + 511], + LowContrast: [offset + 5, offset + 507], + MediumContrast: [offset + 10, offset + 502], + HighContrast: [offset + 25, offset + 487], }); } @@ -110,14 +119,14 @@ describe('image statistics worker ownership', () => { const id = `image-${cycle}`; const worker = await startImage(id, cycle * 100 - 300); expect(worker.terminate).not.toHaveBeenCalled(); - worker.finish(); - await expectRanges(id, cycle * 100 - 300); + await finish(worker); + expectRanges(id, cycle * 100 - 300); expect(worker.terminate).toHaveBeenCalledExactlyOnceWith(); useImageCacheStore().removeImage(id); await nextTick(); expect(useImageStatsStore().stats[id]).toBeUndefined(); } - expect(useMessageStore().messages).toEqual([]); + expect(messageTitles()).toEqual([]); }); it('reclaims a rejected worker while other calculations and later loads succeed', async () => { @@ -125,24 +134,21 @@ describe('image statistics worker ownership', () => { const healthy = await startImage('healthy', -1000); const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); - failed.finish(new Error('Histogram failed')); - await vi.waitFor(() => { - expect(useMessageStore().messages).toHaveLength(1); - }); - expect(useMessageStore().messages[0].title).toBe( - 'Auto range computation failed for image failed' - ); + await finish(failed, new Error('Histogram failed')); + expect(messageTitles()).toEqual([ + 'Auto range computation failed for image failed', + ]); expect(errors).toHaveBeenCalled(); expect(failed.terminate).toHaveBeenCalledExactlyOnceWith(); expect(healthy.terminate).not.toHaveBeenCalled(); expect(useImageStatsStore().getAutoRangeValues('failed')).toEqual({}); - healthy.finish(); - await expectRanges('healthy', -1000); + await finish(healthy); + expectRanges('healthy', -1000); expect(healthy.terminate).toHaveBeenCalledExactlyOnceWith(); const later = await startImage('later', 1000); - later.finish(); - await expectRanges('later', 1000); + await finish(later); + expectRanges('later', 1000); expect(later.terminate).toHaveBeenCalledExactlyOnceWith(); }); @@ -151,15 +157,13 @@ describe('image statistics worker ownership', () => { const healthy = await startImage('healthy'); useImageCacheStore().removeImage('removed'); await nextTick(); - removed.finish(); - await vi.waitFor(() => { - expect(removed.terminate).toHaveBeenCalledExactlyOnceWith(); - }); + await finish(removed); + expect(removed.terminate).toHaveBeenCalledExactlyOnceWith(); expect(useImageStatsStore().stats.removed).toBeUndefined(); expect(healthy.terminate).not.toHaveBeenCalled(); - healthy.finish(); - await expectRanges('healthy'); + await finish(healthy); + expectRanges('healthy'); expect(healthy.terminate).toHaveBeenCalledExactlyOnceWith(); - expect(useMessageStore().messages).toEqual([]); + expect(messageTitles()).toEqual([]); }); }); From 0d745421af72b8310fdde5dc6258efd8ac2de2ed Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Fri, 25 Sep 2026 12:44:42 -0400 Subject: [PATCH 15/39] fix(settings): switch theme with theme.change --- src/components/Settings.vue | 2 +- src/plugins/vuetify.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Settings.vue b/src/components/Settings.vue index 94ca96577..5d81bdf4c 100644 --- a/src/components/Settings.vue +++ b/src/components/Settings.vue @@ -77,7 +77,7 @@ export default defineComponent({ const dark = ref(theme.global.name.value === DarkTheme); watch(dark, (isDark) => { - theme.global.name.value = isDark ? DarkTheme : LightTheme; + theme.change(isDark ? DarkTheme : LightTheme); store.value = theme.global.name.value; }); diff --git a/src/plugins/vuetify.js b/src/plugins/vuetify.js index 3cfa1a0e0..ff3db2278 100644 --- a/src/plugins/vuetify.js +++ b/src/plugins/vuetify.js @@ -48,6 +48,6 @@ const theme = useLocalStorage(ThemeStorageKey, DefaultTheme); if (theme.value !== DarkTheme && theme.value !== LightTheme) { theme.value = DefaultTheme; } -vuetify.theme.global.name.value = theme.value; +vuetify.theme.change(theme.value); export default vuetify; From b01d75f6a187a2c922bb51dfdc3f920a36dd4cab Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Mon, 28 Sep 2026 16:28:46 -0400 Subject: [PATCH 16/39] chore(deps): drop the unused deep-equal dependency --- package-lock.json | 510 ---------------------------------------------- package.json | 2 - 2 files changed, 512 deletions(-) diff --git a/package-lock.json b/package-lock.json index 95d2d2632..ca1fdb4ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,6 @@ "@thi.ng/rasterize": "^1.0.171", "@types/color-name": "^1.1.5", "@types/cors": "^2.8.19", - "@types/deep-equal": "^1.0.4", "@types/express": "^5.0.5", "@types/file-saver": "^2.0.7", "@types/mocha": "^10.0.10", @@ -46,7 +45,6 @@ "core-js": "3.47.0", "cors": "^2.8.5", "cross-env": "^10.1.0", - "deep-equal": "^2.2.3", "dicom-parser": "^1.8.21", "dicomweb-client-typed": "^0.8.6", "eslint": "^9.39.1", @@ -5178,13 +5176,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/deep-equal": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@types/deep-equal/-/deep-equal-1.0.4.tgz", - "integrity": "sha512-tqdiS4otQP4KmY0PR3u6KbZ5EWvhNdUoS/jc93UuK23C220lOZ/9TvjfxdPcKvqwwDVtmtSCrnr0p/2dirAxkA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/emscripten": { "version": "1.41.5", "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", @@ -7360,23 +7351,6 @@ "node": ">= 0.4" } }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/array-ify": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", @@ -9832,39 +9806,6 @@ "node": ">=6" } }, - "node_modules/deep-equal": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", - "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.5", - "es-get-iterator": "^1.1.3", - "get-intrinsic": "^1.2.2", - "is-arguments": "^1.1.1", - "is-array-buffer": "^3.0.2", - "is-date-object": "^1.0.5", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "isarray": "^2.0.5", - "object-is": "^1.1.5", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.1", - "side-channel": "^1.0.4", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1", - "which-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -9934,24 +9875,6 @@ "node": ">=8" } }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/degenerator": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", @@ -10526,27 +10449,6 @@ "node": ">= 0.4" } }, - "node_modules/es-get-iterator": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", - "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", - "has-symbols": "^1.0.3", - "is-arguments": "^1.1.1", - "is-map": "^2.0.2", - "is-set": "^2.0.2", - "is-string": "^1.0.7", - "isarray": "^2.0.5", - "stop-iteration-iterator": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/es-module-lexer": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", @@ -11849,16 +11751,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/geckodriver": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/geckodriver/-/geckodriver-6.1.1.tgz", @@ -12205,19 +12097,6 @@ "node": ">=20.0.0" } }, - "node_modules/has-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -12740,21 +12619,6 @@ "dev": true, "license": "Apache-2.0 OR MIT" }, - "node_modules/internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -12879,41 +12743,6 @@ "progress-events": "^1.0.1" } }, - "node_modules/is-arguments": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", - "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -12921,22 +12750,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-bigint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-bigints": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -12950,23 +12763,6 @@ "node": ">=8" } }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -13016,23 +12812,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-docker": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", @@ -13088,19 +12867,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-natural-number": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/is-natural-number/-/is-natural-number-4.0.1.tgz", @@ -13118,23 +12884,6 @@ "node": ">=0.12.0" } }, - "node_modules/is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-obj": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", @@ -13175,54 +12924,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-stream": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", @@ -13236,41 +12937,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-typed-array": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", @@ -13313,36 +12979,6 @@ ], "license": "MIT" }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-what": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", @@ -15839,23 +15475,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object-is": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", - "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -15866,27 +15485,6 @@ "node": ">= 0.4" } }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -17400,27 +16998,6 @@ "dev": true, "license": "MIT" }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/relateurl": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", @@ -17798,24 +17375,6 @@ "dev": true, "license": "MIT" }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/safe-regex2": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", @@ -18031,22 +17590,6 @@ "node": ">= 0.4" } }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", @@ -18537,20 +18080,6 @@ "dev": true, "license": "MIT" }, - "node_modules/stop-iteration-iterator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "internal-slot": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/stream-buffers": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-3.0.3.tgz", @@ -21338,45 +20867,6 @@ "node": ">= 8" } }, - "node_modules/which-boxed-primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-bigint": "^1.1.0", - "is-boolean-object": "^1.2.1", - "is-number-object": "^1.1.1", - "is-string": "^1.1.1", - "is-symbol": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-collection": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/which-typed-array": { "version": "1.1.20", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", diff --git a/package.json b/package.json index bbda03ac8..04acf74b4 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,6 @@ "@thi.ng/rasterize": "^1.0.171", "@types/color-name": "^1.1.5", "@types/cors": "^2.8.19", - "@types/deep-equal": "^1.0.4", "@types/express": "^5.0.5", "@types/file-saver": "^2.0.7", "@types/mocha": "^10.0.10", @@ -72,7 +71,6 @@ "core-js": "3.47.0", "cors": "^2.8.5", "cross-env": "^10.1.0", - "deep-equal": "^2.2.3", "dicom-parser": "^1.8.21", "dicomweb-client-typed": "^0.8.6", "eslint": "^9.39.1", From 2f4c09e37cc3c33ab2b83c7302b236262b96114a Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Mon, 28 Sep 2026 16:30:56 -0400 Subject: [PATCH 17/39] refactor(segmentation): declare the process workflow props as a type --- src/segmentation/components/ProcessWorkflow.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/segmentation/components/ProcessWorkflow.vue b/src/segmentation/components/ProcessWorkflow.vue index 2377e4f63..66ceecff3 100644 --- a/src/segmentation/components/ProcessWorkflow.vue +++ b/src/segmentation/components/ProcessWorkflow.vue @@ -75,11 +75,11 @@ import { useCurrentImage } from '@/src/composables/useCurrentImage'; import { useToolStore } from '@/src/store/tools'; import { Tools } from '@/src/store/tools/types'; -interface Props { +type Props = { algorithm: ProcessAlgorithm; requiresActiveSegment?: boolean; label?: string; -} +}; const props = withDefaults(defineProps(), { requiresActiveSegment: true, From b43a06cd1785a3c7bdcfa7466e1dc830b2e7b07e Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Mon, 28 Sep 2026 19:09:05 -0400 Subject: [PATCH 18/39] docs(state): name segments rather than groups in state file comments --- src/io/state-file/schema.ts | 3 ++- src/io/state-file/serialize.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/io/state-file/schema.ts b/src/io/state-file/schema.ts index 13a59fc8c..a5e7c2fe0 100644 --- a/src/io/state-file/schema.ts +++ b/src/io/state-file/schema.ts @@ -298,7 +298,8 @@ const RGBAColor = z.tuple([z.number(), z.number(), z.number(), z.number()]); // Provenance of a scene object produced by a processing job. This durable // identity prevents a restored result from being applied twice. Optional and // additive wherever it is used; hand-made state has none. The shape mirrors the -// backend contract's result source and is shared by groups and annotation tools. +// backend contract's result source and is shared by mask bindings, artifacts +// and annotation tools. export const ProcessingResultSource = z.object({ providerId: z.string(), jobId: z.string(), diff --git a/src/io/state-file/serialize.ts b/src/io/state-file/serialize.ts index 9877b1bf0..478f0a88c 100644 --- a/src/io/state-file/serialize.ts +++ b/src/io/state-file/serialize.ts @@ -192,8 +192,8 @@ export function normalizeManifest(manifest: Manifest, zip: JSZip) { } // Dev-only cascade-gap backstop (DCE'd in prod by the NODE_ENV guard). - // Referential integrity of these dataset/view/group-keyed sections is owned by - // the synchronous remove cascade — a load-bearing but UNENFORCED invariant. + // Referential integrity of these dataset/segment/view-keyed sections is owned + // by the synchronous remove cascade: a load-bearing but UNENFORCED invariant. // Each cascade-owning store declares its manifest references next to its // cascade registration (declareManifestRefs); walking those declarations here // detects and REPORTS an orphan in dev/test without mutating output, and From d1ac1f6814ce278acf4d4ff1e4970ecc79bdacb6 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Mon, 28 Sep 2026 21:07:35 -0400 Subject: [PATCH 19/39] refactor(utils): drop helpers that no longer have callers --- src/utils/dataSelection.ts | 2 +- src/utils/index.ts | 34 ---------------------------------- 2 files changed, 1 insertion(+), 35 deletions(-) diff --git a/src/utils/dataSelection.ts b/src/utils/dataSelection.ts index c0eeabd1a..87bfb48f2 100644 --- a/src/utils/dataSelection.ts +++ b/src/utils/dataSelection.ts @@ -26,7 +26,7 @@ const getImageName = (imageID: string) => { return useImageCacheStore().getImageMetadata(imageID)?.name ?? null; }; -export const getSelectionName = (selection: string) => { +const getSelectionName = (selection: string) => { if (isDicomImage(selection)) { return getDisplayName(useDICOMStore().volumeInfo[selection]); } diff --git a/src/utils/index.ts b/src/utils/index.ts index 28f0000a8..e13252273 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -286,23 +286,6 @@ export const TypedArrayConstructorNames = [ 'Float64Array', ]; -/** - * Creates a new typed array of the same type as the source array. - * This utility handles the TypeScript typing issues when using array.constructor. - * - * @param sourceArray The source array to match the type of - * @param arrayLength The length of the new array - * @returns A new array of the same type as sourceArray - */ -export function createTypedArrayLike( - sourceArray: T, - arrayLength: number -): T { - return new (sourceArray.constructor as new (length: number) => T)( - arrayLength - ); -} - // https://stackoverflow.com/a/74823834 type Entries = { [K in keyof T]-?: [K, T[K]]; @@ -312,23 +295,6 @@ type Entries = { export const getEntries = (obj: T) => Object.entries(obj) as Entries; -/** - * Normalizes a list of objects to { order, byKey } - * @param objects - * @param key - * @returns - */ -export function normalizeForStore(objects: T[], key: K) { - type KeyType = T[K]; - const order: KeyType[] = objects.map((obj) => obj[key]); - const byKey = objects.reduce>( - (acc, obj) => ({ ...acc, [obj[key] as string | number | symbol]: obj }), - {} as Record - ); - - return { order, byKey }; -} - export function shortenNumber(value: number) { if (Number.isInteger(value)) { return value.toString(); From 427ff772370812558a46969782c5e414a4d51e04 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Mon, 28 Sep 2026 23:26:56 -0400 Subject: [PATCH 20/39] fix(processing): offer unroutable intent results as downloads --- src/processing/components/JobList.vue | 11 ++++---- .../components/__tests__/JobList.spec.ts | 26 ++++++++++++++++++- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/processing/components/JobList.vue b/src/processing/components/JobList.vue index 0b574d592..c6b5f9770 100644 --- a/src/processing/components/JobList.vue +++ b/src/processing/components/JobList.vue @@ -318,6 +318,7 @@ import { type JobHistoryDisplayRow, } from '@/src/processing/engine/jobHistory'; import { offersSceneLoad } from '@/src/processing/engine/resultFiles'; +import { resultToIntent } from '@/src/processing/engine/resultToIntent'; import { fetchProcessingResult } from '@/src/processing/engine/resultDownload'; const providers = useProcessingJobsStore(); @@ -444,9 +445,9 @@ function resultsFor(job: JobRow): ProcessingResult[] { return providers.jobResults.get(rowKey(job)) ?? []; } -function fileResultsFor(job: JobRow): ProcessingResult[] { - return resultsFor(job).filter((result) => !result.intent); -} +// A result the applier cannot route, intent or not, is an ordinary file. +const fileResultsFor = (job: JobRow) => + resultsFor(job).filter((result) => !resultToIntent(result)); function missingFor(job: JobRow): number { const key = rowKey(job); @@ -483,10 +484,8 @@ function resultHrefFor(result: ProcessingResult): string | undefined { return canFetchUrl(result.url) ? result.url : undefined; } -// Results with an intent are handled by the scene-result pipeline (for example, -// as images or label maps). Downloads are reserved for arbitrary file outputs. function canDownloadResult(result: ProcessingResult): boolean { - return !result.intent && Boolean(resultHrefFor(result)); + return Boolean(resultHrefFor(result)); } const downloadingResultKeys = reactive(new Set()); diff --git a/src/processing/components/__tests__/JobList.spec.ts b/src/processing/components/__tests__/JobList.spec.ts index 1e6e41efd..cd8154baa 100644 --- a/src/processing/components/__tests__/JobList.spec.ts +++ b/src/processing/components/__tests__/JobList.spec.ts @@ -64,7 +64,7 @@ const makeProvider = ( const btnStub = { template: '' }; const slotStub = { template: '
    ' }; -describe('JobList error log', () => { +describe('JobList', () => { let pinia: ReturnType; let wrapper: VueWrapper | undefined; @@ -224,4 +224,28 @@ describe('JobList error log', () => { expect(list.find('.job-row').exists()).toBe(true); expect(provider.getJobHistoryDetail).not.toHaveBeenCalled(); }); + + it('offers a result whose intent this client cannot route as a download', async () => { + const provider = makeProvider({ + getJob: vi.fn().mockResolvedValue(jobStatus(JOB_ID, 'success')), + getResults: vi.fn().mockResolvedValue({ + results: [ + { + id: 'mesh', + intent: 'add-mesh', + name: 'mesh.vtp', + url: 'http://p1/mesh.vtp', + }, + ], + missing: 0, + }), + }); + await submit(provider); + const list = mountList(); + + await expandDetails(list); + + expect(list.find('.result-files').text()).toContain('mesh.vtp'); + expect(list.find('button[title="Download mesh.vtp"]').exists()).toBe(true); + }); }); From 1b5e4b3b0dda9131f25a8a9fe756aacc242965f3 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Mon, 28 Sep 2026 23:33:27 -0400 Subject: [PATCH 21/39] docs(contract): require result ids to be unique within a job --- backend-contract/generated/openapi.json | 2 +- backend-contract/processing/openapi.ts | 10 +++++----- backend-contract/processing/wire.ts | 7 ++++--- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/backend-contract/generated/openapi.json b/backend-contract/generated/openapi.json index 24c1e00d1..829c0358d 100644 --- a/backend-contract/generated/openapi.json +++ b/backend-contract/generated/openapi.json @@ -347,7 +347,7 @@ ], "responses": { "200": { - "description": "The resolved results as the { resultState, intents, missing } envelope (JobResults). Each entry of `intents` is a ResultIntent — a result row carrying a required `id` display key and required `name`/`url`, plus optional/null `mimeType`/`size` file metadata. `missing` counts declared outputs that never arrived plus recorded outputs that cannot be read. Total loss is a valid incomplete response with an empty intents array.", + "description": "The resolved results as the { resultState, intents, missing } envelope (JobResults). Each entry of `intents` is a ResultIntent — a result row carrying a required `id` display key, unique within the job, and required `name`/`url`, plus optional/null `mimeType`/`size` file metadata. `missing` counts declared outputs that never arrived plus recorded outputs that cannot be read. Total loss is a valid incomplete response with an empty intents array.", "content": { "application/json": { "schema": { diff --git a/backend-contract/processing/openapi.ts b/backend-contract/processing/openapi.ts index e9bda206d..0615ff1a7 100644 --- a/backend-contract/processing/openapi.ts +++ b/backend-contract/processing/openapi.ts @@ -429,11 +429,11 @@ const paths = (): Record => ({ description: 'The resolved results as the { resultState, intents, missing } envelope ' + '(JobResults). Each entry of `intents` is a ResultIntent — a result ' + - 'row carrying a required `id` display key and required `name`/`url`, ' + - 'plus optional/null `mimeType`/`size` file metadata. `missing` counts ' + - 'declared outputs that never arrived plus recorded outputs that cannot ' + - 'be read. Total loss is a valid incomplete response with an empty ' + - 'intents array.', + 'row carrying a required `id` display key, unique within the job, and ' + + 'required `name`/`url`, plus optional/null `mimeType`/`size` file ' + + 'metadata. `missing` counts declared outputs that never arrived plus ' + + 'recorded outputs that cannot be read. Total loss is a valid ' + + 'incomplete response with an empty intents array.', content: json(ref('JobResults')), }, '409': resultReadErrorResponse, diff --git a/backend-contract/processing/wire.ts b/backend-contract/processing/wire.ts index f254b612d..42cb29b89 100644 --- a/backend-contract/processing/wire.ts +++ b/backend-contract/processing/wire.ts @@ -196,9 +196,10 @@ export type SegmentDescriptor = z.infer; // The ONE canonical result-list-item shape, shared by every producer, the // client, the generated OpenAPI, the fixtures, and the backend copy. `id` is the -// display key (required, nonempty); `name`/`url` are required; `mimeType`/`size` -// are advisory file metadata that may be null. Every intent branch is built FROM -// this shape, so there is no payload the contract accepts but the client rejects. +// display key, unique within the job (required, nonempty); `name`/`url` are +// required; `mimeType`/`size` are advisory file metadata that may be null. Every +// intent branch is built FROM this shape, so there is no payload the contract +// accepts but the client rejects. export const resultListItemSchema = z.object({ id: z.string().min(1), name: z.string(), From 9a488f264b97ac816bf5a1d43797129381688674 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Mon, 28 Sep 2026 23:42:23 -0400 Subject: [PATCH 22/39] refactor(contract): name the segmentation import schema after its intent --- backend-contract/processing/__tests__/wire.spec.ts | 2 +- backend-contract/processing/wire.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend-contract/processing/__tests__/wire.spec.ts b/backend-contract/processing/__tests__/wire.spec.ts index d0c0097bb..df2f4a22f 100644 --- a/backend-contract/processing/__tests__/wire.spec.ts +++ b/backend-contract/processing/__tests__/wire.spec.ts @@ -291,7 +291,7 @@ describe('result intent fixtures', () => { expect(parsed.source).toMatchObject({ outputId: 'outputLabelmap' }); }); - it('rejects a segment-group source without provider identity', () => { + it('rejects a segmentation source without provider identity', () => { const value = structuredClone( wire['intent.import-segmentation.with-segments'] ) as { source: { providerId?: string } }; diff --git a/backend-contract/processing/wire.ts b/backend-contract/processing/wire.ts index 42cb29b89..9e374692e 100644 --- a/backend-contract/processing/wire.ts +++ b/backend-contract/processing/wire.ts @@ -58,7 +58,7 @@ const stagedDescriptorCommon = { referenceImage: stagedReferenceImageSchema, }; -// A parent-bound labelmap: the segment-group bytes overlaying the image. +// A parent-bound labelmap: the segmentation bytes overlaying the image. const stageLabelmapDescriptorSchema = z.strictObject({ type: z.literal(TYPE_TAG_LABELMAP), ...stagedDescriptorCommon, @@ -227,7 +227,7 @@ const addLayer = z // labels-sidecar case; a `seg.nrrd` with embedded metadata carries none — the // client uses `segments` when present, else the file's own metadata) and an // optional `source` provenance tag (the idempotency key). -const addSegmentGroup = z +const importSegmentation = z .object({ intent: z.literal('import-segmentation'), ...resultListItemSchema.shape, @@ -255,7 +255,7 @@ const addAnnotations = z export const knownResultIntentSchema = z.discriminatedUnion('intent', [ addBaseImage, addLayer, - addSegmentGroup, + importSegmentation, addAnnotations, ]); From 95f92cec311402a729f3fcd773320cb436d245fa Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Mon, 28 Sep 2026 23:43:43 -0400 Subject: [PATCH 23/39] docs(processing): say annotation labels are keyed by segment id --- src/processing/engine/annotationsWire.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/processing/engine/annotationsWire.ts b/src/processing/engine/annotationsWire.ts index 8cc983997..26a217c76 100644 --- a/src/processing/engine/annotationsWire.ts +++ b/src/processing/engine/annotationsWire.ts @@ -57,7 +57,7 @@ export type PolygonToolView = AnnotationToolCoreView & { export type AnnotationKindView = { // Finished tools on ONE image; the caller owns that filter. tools: ReadonlyArray; - // The whole store label map (keyed by label id); encode prunes it. + // Every registry segment keyed by segment id; encode prunes it. labels: Record; }; From b5542446ff466e2e55009c132c378dc29295fd1b Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 29 Sep 2026 01:29:24 -0400 Subject: [PATCH 24/39] fix(segmentation): say segment in the shortcut and merge hints --- src/components/tools/polygon/PolygonTool.vue | 34 +++++++++----------- src/constants.ts | 4 +-- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/src/components/tools/polygon/PolygonTool.vue b/src/components/tools/polygon/PolygonTool.vue index 180a7e6db..6e4234003 100644 --- a/src/components/tools/polygon/PolygonTool.vue +++ b/src/components/tools/polygon/PolygonTool.vue @@ -32,22 +32,18 @@ Rasterize - - - + + + Merge Polygons + +
    @@ -191,8 +187,10 @@ export default defineComponent({ baseOnHover(id, event); }; - const mergePossible = computed( - () => activeToolStore.mergeableTools.length >= 1 + const mergeDisabledReason = computed(() => + activeToolStore.mergeableTools.length >= 1 + ? '' + : 'Shift select multiple polygons that overlap and belong to the same segment.' ); const isCurrentImageCine = computed(() => isCineImage(imageId.value)); @@ -235,7 +233,7 @@ export default defineComponent({ contextMenu, openContextMenu, mergeTools: activeToolStore.mergeSelectedTools, - mergePossible, + mergeDisabledReason, activeToolStore, onHover, overlayInfo, diff --git a/src/constants.ts b/src/constants.ts index 388f2f7a4..5b2c9ef77 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -106,10 +106,10 @@ export const ACTIONS = { }, decrementLabel: { - readable: 'Activate previous label', + readable: 'Select previous segment', }, incrementLabel: { - readable: 'Activate next label', + readable: 'Select next segment', }, ...perSegmentShortcut((index) => ({ From 0a66a487136af5c7937856f8163f2b9cb6d65210 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 29 Sep 2026 01:41:59 -0400 Subject: [PATCH 25/39] refactor(shortcuts): drop select from the native key claims --- src/composables/useKeyboardShortcuts.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/composables/useKeyboardShortcuts.ts b/src/composables/useKeyboardShortcuts.ts index 5ab2a4982..445eafb24 100644 --- a/src/composables/useKeyboardShortcuts.ts +++ b/src/composables/useKeyboardShortcuts.ts @@ -144,11 +144,8 @@ const ARROW_KEYS = ['up', 'down', 'left', 'right'].map( // The keys a control acts on natively, paired with the controls that claim them. const NATIVE_KEY_CLAIMS = [ - { keys: ['enter', ' '], selector: 'input, select, button, [role="button"]' }, - { - keys: ARROW_KEYS, - selector: 'input[type="radio"], input[type="range"], select', - }, + { keys: ['enter', ' '], selector: 'input, button, [role="button"]' }, + { keys: ARROW_KEYS, selector: 'input[type="radio"], input[type="range"]' }, ].map(({ keys, selector }) => ({ keys: new Set(keys), selector })); const isTextEntry = (element: HTMLElement) => { From c09d62bbb5f5c49a5708b5c6ca06ee37fbe12a6f Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 29 Sep 2026 02:42:26 -0400 Subject: [PATCH 26/39] refactor(utils): drop the unused standardizeColor helper --- src/utils/index.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/utils/index.ts b/src/utils/index.ts index e13252273..e0782ad9e 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -2,7 +2,6 @@ import { z } from 'zod'; import { TypedArray } from 'itk-wasm'; import { parseUrl } from '@/src/utils/url'; import { EPSILON } from '../constants'; -import { Maybe } from '../types'; export function identity(arg: T) { return arg; @@ -256,15 +255,6 @@ export const cleanUndefined = (record: T): Partial => Object.entries(record).filter(([, value]) => value !== undefined) ) as Partial; -// converts named colors (red, antiquewhite, etc) to hex -export function standardizeColor(color: Maybe) { - if (!color) return '#ffffff'; - const ctx = document.createElement('canvas').getContext('2d'); - if (!ctx) throw new Error('Could not create canvas context'); - ctx.fillStyle = color; - return ctx.fillStyle; -} - export function zodEnumFromObjKeys(obj: Record) { const [firstKey, ...otherKeys] = Object.keys(obj) as K[]; return z.enum([firstKey, ...otherKeys]); From 8a1b7bd621b36694609e84dad49281f1a1b69805 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 29 Sep 2026 02:43:43 -0400 Subject: [PATCH 27/39] docs(contract): state the label value range a segment descriptor takes --- backend-contract/generated/job-results.schema.json | 3 ++- backend-contract/generated/openapi.json | 3 ++- backend-contract/generated/result-intent.schema.json | 3 ++- backend-contract/processing/wire.ts | 8 +++++++- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/backend-contract/generated/job-results.schema.json b/backend-contract/generated/job-results.schema.json index 7b651e668..c419a9b96 100644 --- a/backend-contract/generated/job-results.schema.json +++ b/backend-contract/generated/job-results.schema.json @@ -155,7 +155,8 @@ "value": { "type": "integer", "minimum": 1, - "maximum": 9007199254740991 + "maximum": 9007199254740991, + "description": "The label value, from 1 to 65535; 0 is background. The client stores labels in at most 16 bits: voxels holding a larger value import as background with a warning, and a segment declared with one arrives empty." }, "name": { "type": "string" diff --git a/backend-contract/generated/openapi.json b/backend-contract/generated/openapi.json index 829c0358d..b80b123ef 100644 --- a/backend-contract/generated/openapi.json +++ b/backend-contract/generated/openapi.json @@ -1244,7 +1244,8 @@ "value": { "type": "integer", "minimum": 1, - "maximum": 9007199254740991 + "maximum": 9007199254740991, + "description": "The label value, from 1 to 65535; 0 is background. The client stores labels in at most 16 bits: voxels holding a larger value import as background with a warning, and a segment declared with one arrives empty." }, "name": { "type": "string" diff --git a/backend-contract/generated/result-intent.schema.json b/backend-contract/generated/result-intent.schema.json index 9ba4f9ec8..b52bad96b 100644 --- a/backend-contract/generated/result-intent.schema.json +++ b/backend-contract/generated/result-intent.schema.json @@ -143,7 +143,8 @@ "value": { "type": "integer", "minimum": 1, - "maximum": 9007199254740991 + "maximum": 9007199254740991, + "description": "The label value, from 1 to 65535; 0 is background. The client stores labels in at most 16 bits: voxels holding a larger value import as background with a warning, and a segment declared with one arrives empty." }, "name": { "type": "string" diff --git a/backend-contract/processing/wire.ts b/backend-contract/processing/wire.ts index 9e374692e..0db0ae2be 100644 --- a/backend-contract/processing/wire.ts +++ b/backend-contract/processing/wire.ts @@ -187,7 +187,13 @@ const colorChannel = z.number().int().min(0).max(255); // A segment descriptor: `value` is a label index >= 1 (0 is reserved // background), `color` is RGBA 0-255. export const segmentDescriptorSchema = z.object({ - value: z.number().int().min(1), + value: z + .number() + .int() + .min(1) + .describe( + 'The label value, from 1 to 65535; 0 is background. The client stores labels in at most 16 bits: voxels holding a larger value import as background with a warning, and a segment declared with one arrives empty.' + ), name: z.string(), color: z.tuple([colorChannel, colorChannel, colorChannel, colorChannel]), visible: z.boolean().optional(), From 7865b5c5a4cc060aa7d308f4edb877a777cf4664 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 29 Sep 2026 03:43:25 -0400 Subject: [PATCH 28/39] docs: describe segments in the quick start and shortcut pages --- docs/configuration_file.md | 2 +- docs/quick_start_guide.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/configuration_file.md b/docs/configuration_file.md index b0531f558..a5fe2da20 100644 --- a/docs/configuration_file.md +++ b/docs/configuration_file.md @@ -298,7 +298,7 @@ match or the configuration is rejected. ## Keyboard Shortcuts -Configure the keys to activate tools, change selected labels, and more. +Configure the keys to activate tools, change the selected segment, and more. All [shortcut actions](https://github.com/Kitware/VolView/blob/main/src/constants.ts#L53) are under the `ACTIONS` variable. To configure a key for an action, add its action name and the key(s) under the `shortcuts` section. For key combinations, use `+` like `Ctrl+f`. diff --git a/docs/quick_start_guide.md b/docs/quick_start_guide.md index 39a5fe7dd..e79cc31ea 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 and Ruler: When the ruler tool selected, the left mouse button is used to place and adjust ruler end-markers. Right clicking on a end-marker displays a pop-up menu for deleting that ruler. Switch to the "Annotations" tab to see a list of annotations made to currently loaded data. Select the location icon next to a listed ruler to jump to its slice. Select the trashcan to delete that ruler. When the paint tool is selected, you can paint in any 2D window. Click on the paint tool a second time to bring up a menu of colors and adjust the brush size. Note: Segment groups and measurements are only visible in the UI when their associated base image's view is selected. ![Paint and Ruler](./assets/11-volview-paint-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) - 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) From c4ee52c4db8f3dd72eb5cd0054b92e39e52ba2b1 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 29 Sep 2026 03:46:14 -0400 Subject: [PATCH 29/39] docs(contract): describe name binding and the staged labelmap file --- .../generated/job-results.schema.json | 3 ++- backend-contract/generated/openapi.json | 6 ++++-- .../generated/result-intent.schema.json | 3 ++- .../stage-input-descriptor.schema.json | 3 ++- .../processing/__tests__/wire.spec.ts | 8 +++----- backend-contract/processing/annotations.ts | 7 +++++-- backend-contract/processing/wire.ts | 18 +++++++++++++----- 7 files changed, 31 insertions(+), 17 deletions(-) diff --git a/backend-contract/generated/job-results.schema.json b/backend-contract/generated/job-results.schema.json index c419a9b96..c1dc3f5bb 100644 --- a/backend-contract/generated/job-results.schema.json +++ b/backend-contract/generated/job-results.schema.json @@ -159,7 +159,8 @@ "description": "The label value, from 1 to 65535; 0 is background. The client stores labels in at most 16 bits: voxels holding a larger value import as background with a warning, and a segment declared with one arrives empty." }, "name": { - "type": "string" + "type": "string", + "description": "Binds the segment by exact name. A segment the client already holds under this name, with no mask on the target image yet, takes these voxels and keeps its own color and visibility. Otherwise the client creates a segment from this descriptor, with a numbered name when the existing one already has a mask on that image." }, "color": { "minItems": 4, diff --git a/backend-contract/generated/openapi.json b/backend-contract/generated/openapi.json index b80b123ef..15816ab98 100644 --- a/backend-contract/generated/openapi.json +++ b/backend-contract/generated/openapi.json @@ -896,7 +896,8 @@ "name", "referenceImage" ], - "additionalProperties": false + "additionalProperties": false, + "description": "The staged bytes are a `.seg.nrrd` labelmap on the reference image's voxel grid. Label values run from 1 to N within each file, in the client's segment order, with 0 as background. Voxels are unsigned 8-bit for up to 255 labels and unsigned 16-bit beyond. Segment names and colors ride in the header." }, { "type": "object", @@ -1248,7 +1249,8 @@ "description": "The label value, from 1 to 65535; 0 is background. The client stores labels in at most 16 bits: voxels holding a larger value import as background with a warning, and a segment declared with one arrives empty." }, "name": { - "type": "string" + "type": "string", + "description": "Binds the segment by exact name. A segment the client already holds under this name, with no mask on the target image yet, takes these voxels and keeps its own color and visibility. Otherwise the client creates a segment from this descriptor, with a numbered name when the existing one already has a mask on that image." }, "color": { "minItems": 4, diff --git a/backend-contract/generated/result-intent.schema.json b/backend-contract/generated/result-intent.schema.json index b52bad96b..7892d5210 100644 --- a/backend-contract/generated/result-intent.schema.json +++ b/backend-contract/generated/result-intent.schema.json @@ -147,7 +147,8 @@ "description": "The label value, from 1 to 65535; 0 is background. The client stores labels in at most 16 bits: voxels holding a larger value import as background with a warning, and a segment declared with one arrives empty." }, "name": { - "type": "string" + "type": "string", + "description": "Binds the segment by exact name. A segment the client already holds under this name, with no mask on the target image yet, takes these voxels and keeps its own color and visibility. Otherwise the client creates a segment from this descriptor, with a numbered name when the existing one already has a mask on that image." }, "color": { "minItems": 4, diff --git a/backend-contract/generated/stage-input-descriptor.schema.json b/backend-contract/generated/stage-input-descriptor.schema.json index ffa29090a..1cb2af2e1 100644 --- a/backend-contract/generated/stage-input-descriptor.schema.json +++ b/backend-contract/generated/stage-input-descriptor.schema.json @@ -42,7 +42,8 @@ "name", "referenceImage" ], - "additionalProperties": false + "additionalProperties": false, + "description": "The staged bytes are a `.seg.nrrd` labelmap on the reference image's voxel grid. Label values run from 1 to N within each file, in the client's segment order, with 0 as background. Voxels are unsigned 8-bit for up to 255 labels and unsigned 16-bit beyond. Segment names and colors ride in the header." }, { "type": "object", diff --git a/backend-contract/processing/__tests__/wire.spec.ts b/backend-contract/processing/__tests__/wire.spec.ts index df2f4a22f..488eac5f0 100644 --- a/backend-contract/processing/__tests__/wire.spec.ts +++ b/backend-contract/processing/__tests__/wire.spec.ts @@ -380,11 +380,9 @@ describe('result intent fixtures', () => { }); it('rejects a wrong-length segment color (the tuple-length parity pin)', () => { - // The negative fixture carries a 3-element color. The STRICT union must - // reject it — and the generated JSON Schema must agree (backend side: - // test_contract_fixtures.py), so both validators close fixed-length - // tuples identically. The full union still accepts the row, demoted to an - // ordinary result with no state action (the designed fail-open). + // Only the strict known-intent union rejects the 3-element color; the + // published result-intent schema accepts the row and demotes it to an + // ordinary result with no state action. const short = loadFixture('negative/wrong-length-color.json'); expect(knownResultIntentSchema.safeParse(short).success).toBe(false); expect(resultIntentSchema.safeParse(short).success).toBe(true); diff --git a/backend-contract/processing/annotations.ts b/backend-contract/processing/annotations.ts index 07588353c..431c221e5 100644 --- a/backend-contract/processing/annotations.ts +++ b/backend-contract/processing/annotations.ts @@ -1,6 +1,9 @@ // Vector annotations staged into a task or returned as a result. Coordinates -// are world LPS millimeters. Tool records exclude session identity and state; -// labels are namespaced by tool kind because the client stores are independent. +// are world LPS millimeters. Tool records exclude session identity and state. +// Labels are namespaced by tool kind on the wire, but the client binds every +// kind into one segment registry by name: a name repeated across kinds is one +// segment, the first kind to bind a new name (rulers, rectangles, polygons) +// sets its style, and a label no tool references creates nothing. // Unknown envelope fields survive round-trip without gaining behavior. import { z } from 'zod'; diff --git a/backend-contract/processing/wire.ts b/backend-contract/processing/wire.ts index 0db0ae2be..df394d50b 100644 --- a/backend-contract/processing/wire.ts +++ b/backend-contract/processing/wire.ts @@ -59,10 +59,14 @@ const stagedDescriptorCommon = { }; // A parent-bound labelmap: the segmentation bytes overlaying the image. -const stageLabelmapDescriptorSchema = z.strictObject({ - type: z.literal(TYPE_TAG_LABELMAP), - ...stagedDescriptorCommon, -}); +const stageLabelmapDescriptorSchema = z + .strictObject({ + type: z.literal(TYPE_TAG_LABELMAP), + ...stagedDescriptorCommon, + }) + .describe( + "The staged bytes are a `.seg.nrrd` labelmap on the reference image's voxel grid. Label values run from 1 to N within each file, in the client's segment order, with 0 as background. Voxels are unsigned 8-bit for up to 255 labels and unsigned 16-bit beyond. Segment names and colors ride in the header." + ); // A parent-bound annotations file: the vector annotations (rulers, rectangles, // polygons) drawn on the image, as the `annotations.ts` interchange format. @@ -194,7 +198,11 @@ export const segmentDescriptorSchema = z.object({ .describe( 'The label value, from 1 to 65535; 0 is background. The client stores labels in at most 16 bits: voxels holding a larger value import as background with a warning, and a segment declared with one arrives empty.' ), - name: z.string(), + name: z + .string() + .describe( + 'Binds the segment by exact name. A segment the client already holds under this name, with no mask on the target image yet, takes these voxels and keeps its own color and visibility. Otherwise the client creates a segment from this descriptor, with a numbered name when the existing one already has a mask on that image.' + ), color: z.tuple([colorChannel, colorChannel, colorChannel, colorChannel]), visible: z.boolean().optional(), }); From c849bbe2d70c12ab4458092d58e4be2220779214 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 29 Sep 2026 09:54:55 -0400 Subject: [PATCH 30/39] test: reuse painting setup in segmentation exports --- tests/specs/save-large-labelmap.e2e.ts | 15 +-------------- tests/specs/seg-nrrd-export.e2e.ts | 15 +-------------- 2 files changed, 2 insertions(+), 28 deletions(-) diff --git a/tests/specs/save-large-labelmap.e2e.ts b/tests/specs/save-large-labelmap.e2e.ts index 801cd3f3d..e3071dde4 100644 --- a/tests/specs/save-large-labelmap.e2e.ts +++ b/tests/specs/save-large-labelmap.e2e.ts @@ -74,24 +74,11 @@ describe('Save large labelmap', function () { await volViewPage.open(`?urls=[tmp/${manifestFileName}]`); await volViewPage.waitForViews(DOWNLOAD_TIMEOUT * 6); - // Activate paint tool — creates a segment group await volViewPage.activatePaint(); // Paint a stroke to allocate the labelmap const views2D = await volViewPage.getViews2D(); - const canvas = await views2D[0].$('canvas'); - const location = await canvas.getLocation(); - const size = await canvas.getSize(); - const cx = Math.round(location.x + size.width / 2); - const cy = Math.round(location.y + size.height / 2); - - await browser - .action('pointer') - .move({ x: cx, y: cy }) - .down() - .move({ x: cx + 20, y: cy }) - .up() - .perform(); + await volViewPage.paintStrokeOnView(views2D[0]); const notificationsBefore = await volViewPage.getNotificationsCount(); diff --git a/tests/specs/seg-nrrd-export.e2e.ts b/tests/specs/seg-nrrd-export.e2e.ts index 2fed7e2f8..3e036e0b8 100644 --- a/tests/specs/seg-nrrd-export.e2e.ts +++ b/tests/specs/seg-nrrd-export.e2e.ts @@ -42,24 +42,11 @@ describe('Slicer-compatible seg.nrrd export', function () { const config = { io: { segmentGroupSaveFormat: 'seg.nrrd' } }; await openConfigAndDataset(config, 'seg-nrrd-export', ONE_CT_SLICE_DICOM); - // Activate paint tool — creates a segment group await volViewPage.activatePaint(); // Paint a stroke so the labelmap has data const views2D = await volViewPage.getViews2D(); - const canvas = await views2D[0].$('canvas'); - const location = await canvas.getLocation(); - const size = await canvas.getSize(); - const cx = Math.round(location.x + size.width / 2); - const cy = Math.round(location.y + size.height / 2); - - await browser - .action('pointer') - .move({ x: cx, y: cy }) - .down() - .move({ x: cx + 20, y: cy }) - .up() - .perform(); + await volViewPage.paintStrokeOnView(views2D[0]); // Save session — downloads a .volview.zip containing the seg.nrrd const sessionFileName = await volViewPage.saveSession(); From 299a4b2a14d09553f6ded8fdec1392ef044cc4be Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 29 Sep 2026 14:13:59 -0400 Subject: [PATCH 31/39] fix(segmentation): disable overlap control when paint is inactive --- src/segmentation/components/PaintControls.vue | 23 ++++++++++++++----- .../segment-controls-accessibility.e2e.ts | 15 ++++++++++-- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/segmentation/components/PaintControls.vue b/src/segmentation/components/PaintControls.vue index eba877d97..8eb28b58d 100644 --- a/src/segmentation/components/PaintControls.vue +++ b/src/segmentation/components/PaintControls.vue @@ -175,16 +175,23 @@
    -
    +
    - On: painting and rasterizing keep other segments' voxels, so - segments overlap. Off: they take voxels from unlocked segments - and go around locked ones. + +
    toolStore.paintUnavailableReason || diff --git a/tests/specs/segment-controls-accessibility.e2e.ts b/tests/specs/segment-controls-accessibility.e2e.ts index 9e1b49053..2918024c2 100644 --- a/tests/specs/segment-controls-accessibility.e2e.ts +++ b/tests/specs/segment-controls-accessibility.e2e.ts @@ -63,10 +63,10 @@ describe('Segment control accessibility', () => { await $('button.v-expansion-panel-title*=Paint').click(); const erase = $('button.mode-button*=Erase'); const sync = $('input[aria-label="Sync Views"]'); + const overlap = $('input[aria-label="Allow Overlap"]'); await expect(erase).toBeDisabled(); await expect(sync).toBeDisabled(); - // It also decides how a polygon rasterizes, which needs no brush. - await expect($('input[aria-label="Allow Overlap"]')).toBeEnabled(); + await expect(overlap).toBeDisabled(); // Reached by keyboard from the panel title, so no pointer position is // involved: the disabled modes first, then the brush parameters. @@ -85,10 +85,21 @@ describe('Segment control accessibility', () => { await expect(reason).toHaveText( 'Select the Paint tool to adjust the brush' ); + await browser.keys('Tab'); + const overlapControls = $('.paint-switches > div:first-child'); + await expect(overlapControls).toBeFocused(); + const overlapReason = await tooltipOf(overlapControls); + await expect(overlapReason).toBeDisplayed(); + await expect(overlapReason).toHaveText( + 'Select the Paint tool to adjust the brush' + ); await AppPage.activatePaint(); await expect(erase).toBeEnabled(); await expect(sync).toBeEnabled(); + await expect(overlap).toBeEnabled(); + await AppPage.activateRectangle(); + await expect(overlap).toBeDisabled(); }); it('keeps the segment editor usable at 375px and side by side on desktop', async () => { From c16a32dd1744da6859608df7cae8e64a2e3592e9 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 29 Sep 2026 14:14:15 -0400 Subject: [PATCH 32/39] fix(segmentation): remove deletion success notifications --- src/segmentation/README.md | 4 +- .../__tests__/deleteSegment.spec.ts | 8 +-- src/segmentation/components/SegmentList.vue | 4 +- .../components/__tests__/SegmentList.spec.ts | 50 ++++++++++--------- .../composables/useSegmentEditing.ts | 4 +- src/segmentation/deleteSegment.ts | 45 +---------------- tests/specs/annotations-sidebar.e2e.ts | 8 +-- 7 files changed, 42 insertions(+), 81 deletions(-) diff --git a/src/segmentation/README.md b/src/segmentation/README.md index 98c18f698..ef30f012e 100644 --- a/src/segmentation/README.md +++ b/src/segmentation/README.md @@ -12,8 +12,8 @@ image; the mask's bounded labelmap is allocated only when needed. appearance. Stores holding masks or annotation tools declare their segment references on the registry, which hands them the removal. Removing the last image deletes every segment no config entry holds. -- `deleteSegment.ts` deletes a segment with its masks and shapes and reports - what went with it. +- `deleteSegment.ts` guards locked segments before deleting their masks and + shapes. - `store.ts` owns mask identity, attachment, lookup, and lifecycle. Import and restore use its operations to create records and attach prepared storage. - `masks/` contains allocation, growth, overlap operations, and voxel access. diff --git a/src/segmentation/__tests__/deleteSegment.spec.ts b/src/segmentation/__tests__/deleteSegment.spec.ts index dc45ee6b6..913795f0f 100644 --- a/src/segmentation/__tests__/deleteSegment.spec.ts +++ b/src/segmentation/__tests__/deleteSegment.spec.ts @@ -2,9 +2,9 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { createPinia, setActivePinia } from 'pinia'; import { createSegmentRegistry } from '@/src/segmentation/segmentRegistry'; -import { deleteSegmentAndReport } from '@/src/segmentation/deleteSegment'; +import { deleteUnlockedSegment } from '@/src/segmentation/deleteSegment'; -describe('deleteSegmentAndReport', () => { +describe('deleteUnlockedSegment', () => { beforeEach(() => { setActivePinia(createPinia()); }); @@ -13,7 +13,7 @@ describe('deleteSegmentAndReport', () => { const registry = createSegmentRegistry(); const id = registry.addSegment({ name: 'Tumor', locked: true }); - deleteSegmentAndReport(registry, id); + deleteUnlockedSegment(registry, id); expect(registry.getSegment(id)).toBeDefined(); }); @@ -22,7 +22,7 @@ describe('deleteSegmentAndReport', () => { const registry = createSegmentRegistry(); const id = registry.addSegment({ name: 'Tumor' }); - deleteSegmentAndReport(registry, id); + deleteUnlockedSegment(registry, id); expect(registry.getSegment(id)).toBeUndefined(); }); diff --git a/src/segmentation/components/SegmentList.vue b/src/segmentation/components/SegmentList.vue index 311faee01..cdc82c805 100644 --- a/src/segmentation/components/SegmentList.vue +++ b/src/segmentation/components/SegmentList.vue @@ -10,7 +10,7 @@ import SaveSegmentationDialog from '@/src/segmentation/components/SaveSegmentati import SegmentEditor from '@/src/segmentation/components/SegmentEditor.vue'; import SegmentListActions from '@/src/segmentation/components/SegmentListActions.vue'; import { useCurrentImage } from '@/src/composables/useCurrentImage'; -import { deleteSegmentAndReport } from '@/src/segmentation/deleteSegment'; +import { deleteUnlockedSegment } from '@/src/segmentation/deleteSegment'; import { useSegmentEditing } from '@/src/segmentation/composables/useSegmentEditing'; import { pulseSegmentMask } from '@/src/segmentation/rendering/revealPulse'; import { @@ -408,7 +408,7 @@ const { @reveal="revealSlice(item)" @edit="editing.startEditing(item.id)" @toggle-visible="toggleVisible(item.id)" - @delete="deleteSegmentAndReport(registry, item.id)" + @delete="deleteUnlockedSegment(registry, item.id)" /> diff --git a/src/segmentation/components/__tests__/SegmentList.spec.ts b/src/segmentation/components/__tests__/SegmentList.spec.ts index 255af008e..860bc3414 100644 --- a/src/segmentation/components/__tests__/SegmentList.spec.ts +++ b/src/segmentation/components/__tests__/SegmentList.spec.ts @@ -1086,7 +1086,7 @@ describe('locked segment editor routes', () => { ); }); -describe('deleting a segment says what went with it', () => { +describe('deleting a segment without a success notification', () => { beforeEach(async () => { setActivePinia(createPinia()); await seatSpecImage('img-1'); @@ -1115,31 +1115,40 @@ describe('deleting a segment says what went with it', () => { await nextTick(); }; - it('counts the masks, the images they were on, and the annotations', async () => { + const expectDeleted = async (wrapper: VueWrapper) => { + for (const imageId of ['img-1', 'img-2']) { + await showImage(imageId); + expect(rowIds(wrapper)).toEqual([]); + expect( + wrapper + .get('[data-testid="save-segments-button"]') + .attributes('disabled') + ).toBeDefined(); + } + expect(messageTitles()).toEqual([]); + }; + + it('removes a segment and its painted masks across images', async () => { const segmentId = spreadSegment(['img-1', 'img-2']); const wrapper = mountList(); await nextTick(); await deleteRow(wrapper, segmentId); - expect(messageTitles()).toEqual([ - 'Deleted 2 masks on 2 images and 2 annotations', - ]); + await expectDeleted(wrapper); }); - it('says one of each in the singular', async () => { + it('removes a segment with content only on another image', async () => { const segmentId = spreadSegment(['img-2']); const wrapper = mountList(); await nextTick(); await deleteRow(wrapper, segmentId); - expect(messageTitles()).toEqual([ - 'Deleted 1 mask on 1 image and 1 annotation', - ]); + await expectDeleted(wrapper); }); - it('names only what the segment had', async () => { + it('deletes painted and annotation-only segments', async () => { const painted = makeMask('img-1', 'Painted'); seedVoxel(painted.maskId, [1, 1, 0]); const shaped = segments().addSegment({ name: 'Shaped' }); @@ -1153,18 +1162,16 @@ describe('deleting a segment says what went with it', () => { await nextTick(); await deleteRow(wrapper, painted.segmentId); + expect(rowIds(wrapper)).toEqual([shaped]); await deleteRow(wrapper, shaped); - expect(messageTitles()).toEqual([ - 'Deleted 1 mask on 1 image', - 'Deleted 1 annotation', - ]); + await expectDeleted(wrapper); }); // A record is minted the moment a segment is resolved as an edit target, so // an image can hold one for a segment that was never painted there. Deleting // drops the record, but there was nothing on that image to lose. - it('counts no mask on an image the segment was only resolved on', async () => { + it('deletes empty mask records with their segment', async () => { const recorded = makeMask('img-1', 'Resolved'); const allocated = maskOn('img-2', recorded.segmentId); store().maskVoxels(allocated.id).materialize(); @@ -1173,8 +1180,7 @@ describe('deleting a segment says what went with it', () => { await deleteRow(wrapper, recorded.segmentId); - expect(rowIds(wrapper)).toEqual([]); - expect(messageTitles()).toEqual([]); + await expectDeleted(wrapper); }); it('stays quiet when the segment held nothing', async () => { @@ -1184,11 +1190,10 @@ describe('deleting a segment says what went with it', () => { await deleteRow(wrapper, empty); - expect(rowIds(wrapper)).toEqual([]); - expect(messageTitles()).toEqual([]); + await expectDeleted(wrapper); }); - it('reports the same cascade when the editor deletes', async () => { + it('removes the segment and its painted masks when the editor deletes', async () => { const segmentId = spreadSegment(['img-1', 'img-2']); const wrapper = mountList(); await nextTick(); @@ -1199,10 +1204,7 @@ describe('deleting a segment says what went with it', () => { editor(wrapper).vm.$emit('delete'); await nextTick(); - expect(rowIds(wrapper)).toEqual([]); - expect(messageTitles()).toEqual([ - 'Deleted 2 masks on 2 images and 2 annotations', - ]); + await expectDeleted(wrapper); }); }); diff --git a/src/segmentation/composables/useSegmentEditing.ts b/src/segmentation/composables/useSegmentEditing.ts index a31c227bc..389584e0e 100644 --- a/src/segmentation/composables/useSegmentEditing.ts +++ b/src/segmentation/composables/useSegmentEditing.ts @@ -4,7 +4,7 @@ import type { SegmentRegistry } from '@/src/segmentation/segmentRegistry'; import type { Maybe } from '@/src/types'; import { cssColorToRGBA } from '@/src/segmentation/color'; import { cleanUndefined } from '@/src/utils'; -import { deleteSegmentAndReport } from '@/src/segmentation/deleteSegment'; +import { deleteUnlockedSegment } from '@/src/segmentation/deleteSegment'; /** * The segment edit dialog: one editor, one set of fields, one place that @@ -86,7 +86,7 @@ export function useSegmentEditing(registry: SegmentRegistry) { // Deleting a segment takes its masks on every image and its shapes with it. function deleteEditingSegment() { const id = editingSegmentId.value; - if (id) deleteSegmentAndReport(registry, id); + if (id) deleteUnlockedSegment(registry, id); stopEditing(false); } diff --git a/src/segmentation/deleteSegment.ts b/src/segmentation/deleteSegment.ts index 8d9389cea..fa426a5e8 100644 --- a/src/segmentation/deleteSegment.ts +++ b/src/segmentation/deleteSegment.ts @@ -1,40 +1,6 @@ -import { maskHasContent } from '@/src/segmentation/model'; import type { SegmentRegistry } from '@/src/segmentation/segmentRegistry'; -import { useSegmentationStore } from '@/src/segmentation/store'; -import { useMessageStore } from '@/src/store/messages'; -import { AnnotationToolStoreMap } from '@/src/store/tools'; -import { plural } from '@/src/utils'; -/** - * What deleting a segment is about to take with it, counted before the cascade - * runs: its mask on every image, and every finished annotation naming it. A - * tool still being placed is not counted, because the cascade leaves it alone, - * and neither is a mask record with nothing in it: the cascade drops the - * record, but the user never put anything on that image to lose. - */ -function countCascade(segmentId: string) { - // A segment has at most one mask per image, so this counts both. - const images = useSegmentationStore() - .masksOfSegment(segmentId) - .filter(maskHasContent).length; - const annotations = Object.values(AnnotationToolStoreMap).reduce( - (total, useStore) => - total + - useStore().finishedTools.filter((tool) => tool.segmentId === segmentId) - .length, - 0 - ); - return { images, annotations }; -} - -/** - * Deletes a segment and says what went with it. The cascade reaches masks on - * images this one is not viewing and annotations on other slices and axes, so - * its scope is invisible from here and there is no undo: the same reason - * `removeSelectedTools` reports its count. No dialog asks first, which is what - * the rest of the app does. - */ -export function deleteSegmentAndReport( +export function deleteUnlockedSegment( registry: SegmentRegistry, segmentId: string ) { @@ -43,14 +9,5 @@ export function deleteSegmentAndReport( registry.appearanceOf(segmentId).locked ) return; - const { images, annotations } = countCascade(segmentId); registry.deleteSegment(segmentId); - - const removed = [ - images > 0 && - `${images} ${plural(images, 'mask')} on ${images} ${plural(images, 'image')}`, - annotations > 0 && `${annotations} ${plural(annotations, 'annotation')}`, - ].filter((part): part is string => !!part); - if (removed.length > 0) - useMessageStore().addInfo(`Deleted ${removed.join(' and ')}`); } diff --git a/tests/specs/annotations-sidebar.e2e.ts b/tests/specs/annotations-sidebar.e2e.ts index 57bf8d7ed..c37c39d4e 100644 --- a/tests/specs/annotations-sidebar.e2e.ts +++ b/tests/specs/annotations-sidebar.e2e.ts @@ -51,7 +51,7 @@ const DRAWING_TOOLS = [ ]; describe('Annotations sidebar', () => { - it('deletes a segment with its painted mask and measurement and reports what was removed', async () => { + it('deletes a segment with its painted mask and measurement without a notification', async () => { const { axialView } = await placeRectangle(); await volViewPage.activatePaint(); await volViewPage.paintStrokeOnView(axialView); @@ -67,9 +67,11 @@ describe('Annotations sidebar', () => { await expect(axialView.$('svg rect')).not.toExist(); await expect(volViewPage.saveSegmentsButtons[0]).toBeDisabled(); await volViewPage.notifications.click(); - await expect($('.message-center .header > span')).toHaveText( - 'Deleted 1 mask on 1 image and 1 annotation' + await expect($('.message-center')).toBeDisplayed(); + const titles = await $$('.message-center .header > span').map((title) => + title.getText() ); + expect(titles.some((title) => title.startsWith('Deleted '))).toBe(false); }); it('keeps the Segments list in place and selected across tool switches', async () => { From 299c608fc0203e3dafa798e40d946c07360a286e Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 29 Sep 2026 16:18:16 -0400 Subject: [PATCH 33/39] feat(paint): match brush cursor to selected segment color --- src/components/tools/paint/PaintWidget2D.vue | 9 +++++++++ src/vtk/PaintBrushContextRepresentation/index.js | 2 +- src/vtk/PaintWidget/state.ts | 6 +++--- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/components/tools/paint/PaintWidget2D.vue b/src/components/tools/paint/PaintWidget2D.vue index f4c4ea4d4..45308f774 100644 --- a/src/components/tools/paint/PaintWidget2D.vue +++ b/src/components/tools/paint/PaintWidget2D.vue @@ -24,6 +24,7 @@ import { VtkViewContext } from '@/src/components/vtk/context'; import { Maybe } from '@/src/types'; import { PaintMode } from '@/src/core/tools/paint'; import { usePaintInteractionMode } from '@/src/segmentation/composables/usePaintInteractionMode'; +import { useSegmentStore } from '@/src/segmentation/segments'; import eyedropperCursor from '@/src/assets/eyedropper-cursor.svg?url'; import { useActionHeld } from '@/src/composables/useKeyboardShortcuts'; @@ -70,6 +71,14 @@ export default defineComponent({ // --- widget representation config --- // + const { segments } = useSegmentStore(); + watchEffect(() => { + const [r, g, b] = segments.appearanceOf( + segments.selectedSegmentId.value + ).color; + widgetState.getBrush().setColor3([r, g, b]); + }); + // Every mask uses the parent voxel grid. Selection and mask growth do not // change the brush's world-space footprint. watchEffect(() => { diff --git a/src/vtk/PaintBrushContextRepresentation/index.js b/src/vtk/PaintBrushContextRepresentation/index.js index 79396c9f9..66d70c964 100644 --- a/src/vtk/PaintBrushContextRepresentation/index.js +++ b/src/vtk/PaintBrushContextRepresentation/index.js @@ -122,7 +122,6 @@ function vtkPaintBrushContextRepresentation(publicAPI, model) { const actorProperty = model.pipelines.brush.actor.getProperty(); actorProperty.setLineWidth(2); - actorProperty.setColor([1, 0, 0]); actorProperty.setDisplayLocation(DisplayLocation.FOREGROUND); actorProperty.setRepresentation(Representation.SURFACE); @@ -135,6 +134,7 @@ function vtkPaintBrushContextRepresentation(publicAPI, model) { const stencil = widgetState.getStencil(); const brush = widgetState.getBrush(); + actorProperty.setColor(brush.getColor3().map((channel) => channel / 255)); const { indexToWorld, worldToIndex } = model; if (stencil && brush.getOrigin()) { diff --git a/src/vtk/PaintWidget/state.ts b/src/vtk/PaintWidget/state.ts index b74dfeeb2..6d251a019 100644 --- a/src/vtk/PaintWidget/state.ts +++ b/src/vtk/PaintWidget/state.ts @@ -10,8 +10,8 @@ export interface PaintPointWidgetState extends vtkWidgetState { getScale1(): number; setVisible(visible: boolean): boolean; getVisible(): boolean; - setColor(color: number): boolean; - getColor(): number; + setColor3(color: Vector3): boolean; + getColor3(): Vector3; } export interface PaintWidgetState extends vtkWidgetState { @@ -26,7 +26,7 @@ export default function generateState() { .addStateFromMixin({ labels: ['brush'], name: 'brush', - mixins: ['origin', 'scale1', 'visible', 'color'], + mixins: ['origin', 'scale1', 'visible', 'color3'], initialValues: { scale1: 1, origin: null, From ef6ea7485a7c4e0026fce02b9a49f930bce24f3a Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 29 Sep 2026 16:33:22 -0400 Subject: [PATCH 34/39] fix(annotations): remove deletion notifications --- .../__tests__/removeSelectedTools.spec.ts | 30 ------------------- src/store/tools/index.ts | 21 ++----------- 2 files changed, 3 insertions(+), 48 deletions(-) diff --git a/src/store/tools/__tests__/removeSelectedTools.spec.ts b/src/store/tools/__tests__/removeSelectedTools.spec.ts index e455706c2..de0723b0f 100644 --- a/src/store/tools/__tests__/removeSelectedTools.spec.ts +++ b/src/store/tools/__tests__/removeSelectedTools.spec.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { useMessageStore } from '@/src/store/messages'; import { setActivePinia, createPinia } from 'pinia'; import { nextTick } from 'vue'; import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; @@ -89,33 +88,4 @@ describe('removeSelectedTools', () => { expect(useRulerStore().toolByID).toHaveProperty(ruler); }); - - // the selection can hold annotations with no visible cue in the current view - // (hidden, other slices, other axes) and there is no undo - it('reports how many annotations were deleted', () => { - const selectionStore = useToolSelectionStore(); - selectionStore.addSelection(addRuler(), AnnotationToolType.Ruler); - selectionStore.addSelection(addRectangle(), AnnotationToolType.Rectangle); - - removeSelectedTools(); - - const messages = useMessageStore().messages; - expect(messages.at(-1)?.title).toBe('Deleted 2 annotations'); - }); - - it('uses the singular for a single deleted annotation', () => { - useToolSelectionStore().addSelection(addRuler(), AnnotationToolType.Ruler); - - removeSelectedTools(); - - expect(useMessageStore().messages.at(-1)?.title).toBe( - 'Deleted 1 annotation' - ); - }); - - it('says nothing when nothing was deleted', () => { - removeSelectedTools(); - - expect(useMessageStore().messages).toHaveLength(0); - }); }); diff --git a/src/store/tools/index.ts b/src/store/tools/index.ts index 850fa9ae6..9d061507e 100644 --- a/src/store/tools/index.ts +++ b/src/store/tools/index.ts @@ -8,8 +8,6 @@ import { useCrosshairsToolStore } from './crosshairs'; import { usePaintToolStore } from './paint'; import { useRulerStore } from './rulers'; import { useRectangleStore } from './rectangles'; -import { useMessageStore } from '@/src/store/messages'; -import { plural } from '@/src/utils'; import { AnnotationToolType, IToolStore, Tools } from './types'; import { usePolygonStore } from './polygons'; import { useToolSelectionStore } from './toolSelection'; @@ -86,22 +84,9 @@ export function useAnnotationToolStore( export function removeSelectedTools() { const selectionStore = useToolSelectionStore(); - // count what was actually removed: a selection entry can outlive its tool, - // and removeTool is a no-op for one that is already gone - const removed = [...selectionStore.selection].filter(({ id, type }) => { - const store = useAnnotationToolStore(type); - if (!(id in store.toolByID)) return false; - store.removeTool(id); - return true; - }).length; - - // the selection can hold annotations with no visible cue in the current view - // (hidden, other slices, other axes) and there is no undo, so say what went - if (removed > 0) { - useMessageStore().addInfo( - `Deleted ${removed} ${plural(removed, 'annotation')}` - ); - } + [...selectionStore.selection].forEach(({ id, type }) => { + useAnnotationToolStore(type).removeTool(id); + }); // clears any entry whose tool was already gone selectionStore.clearSelection(); From a78af7ba629ab4576281700e99107c8a3a883536 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 29 Sep 2026 16:41:38 -0400 Subject: [PATCH 35/39] style(paint): distribute mode buttons across the panel --- src/segmentation/components/PaintControls.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/segmentation/components/PaintControls.vue b/src/segmentation/components/PaintControls.vue index 8eb28b58d..482b0b1f0 100644 --- a/src/segmentation/components/PaintControls.vue +++ b/src/segmentation/components/PaintControls.vue @@ -31,7 +31,7 @@ v-model="interactionMode" mandatory selected-class="selected" - class="d-flex flex-wrap align-center justify-start ga-2" + class="d-flex flex-wrap align-center justify-space-between ga-2 w-100" > Date: Tue, 29 Sep 2026 19:52:46 -0400 Subject: [PATCH 36/39] fix(state): snapshot annotation identities before mask encoding --- .../__tests__/annotationSaveSnapshot.spec.ts | 107 ++++++++++++++++++ src/io/state-file/serialize.ts | 9 +- 2 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 src/io/state-file/__tests__/annotationSaveSnapshot.spec.ts diff --git a/src/io/state-file/__tests__/annotationSaveSnapshot.spec.ts b/src/io/state-file/__tests__/annotationSaveSnapshot.spec.ts new file mode 100644 index 000000000..4811e7105 --- /dev/null +++ b/src/io/state-file/__tests__/annotationSaveSnapshot.spec.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import JSZip from 'jszip'; + +import { MANIFEST, serialize } from '@/src/io/state-file/serialize'; +import { ManifestSchema } from '@/src/io/state-file/schema'; +import { + addMask, + inMemoryArtifactIO, + seatImage, + seedVoxel, + store, +} from '@/src/segmentation/__tests__/segmentMaskFixtures'; +import { useSegmentStore } from '@/src/segmentation/segments'; +import { seatDataSource } from '@/src/store/__tests__/datasetFixtures'; +import { useRulerStore } from '@/src/store/tools/rulers'; +import { defer } from '@/src/utils'; + +const readManifest = async (blob: Blob) => { + const zip = await JSZip.loadAsync(blob); + return ManifestSchema.parse( + JSON.parse(await zip.file(MANIFEST)!.async('string')) + ); +}; + +beforeEach(() => setActivePinia(createPinia())); +afterEach(() => vi.restoreAllMocks()); + +it.each(['encoding', 'queued update'])( + 'saves matching annotation identities when edited during %s', + async (phase) => { + await seatImage('ct'); + seatDataSource('ct', { type: 'uri', uri: '/ct.nrrd', name: 'ct.nrrd' }); + const maskId = addMask('ct', 'Original'); + seedVoxel(maskId, [0, 0, 0]); + const segments = useSegmentStore(); + const originalSegment = store().getMask(maskId).segmentId; + segments.segments.updateSegment(originalSegment, { + color: [0, 0, 255, 255], + }); + const rulers = useRulerStore(); + const rulerId = rulers.addRuler({ + imageID: 'ct', + firstPoint: [0, 0, 0], + secondPoint: [3, 0, 0], + segmentId: originalSegment, + }); + const reassign = () => { + const segmentId = segments.segments.mintSegment({ + name: 'Tumor', + color: [255, 0, 0, 255], + }); + rulers.updateRuler(rulerId, { segmentId }); + }; + const writeSegments = segments.serialize; + const segmentWriter = vi + .spyOn(segments, 'serialize') + .mockImplementation((state) => { + writeSegments(state); + if (phase === 'queued update') queueMicrotask(reassign); + }); + const io = inMemoryArtifactIO(); + const entered = defer(); + const released = defer(); + const writeMasks = store().serialize; + vi.spyOn(store(), 'serialize').mockImplementation((state) => + writeMasks(state, { + read: io.read, + write: async (...args) => { + const bytes = await io.write(args[0], args[1]); + entered.resolve(); + await released.promise; + return bytes; + }, + }) + ); + + const saving = serialize(); + await entered.promise; + if (phase === 'encoding') reassign(); + released.resolve(); + const saved = await readManifest(await saving); + segmentWriter.mockRestore(); + + const later = await readManifest(await serialize()); + const laterRuler = later.tools!.rulers!.tools[0]; + expect( + later.segments?.find(({ id }) => id === laterRuler.segmentId) + ).toMatchObject({ name: 'Tumor', color: [255, 0, 0, 255] }); + + setActivePinia(createPinia()); + await seatImage('restored'); + const { segmentIdMap } = useSegmentStore().deserialize(saved); + const restored = useRulerStore(); + restored.deserialize(saved, { ct: 'restored' }, segmentIdMap); + const [ruler] = restored.serializeTools().tools; + expect(ruler).toMatchObject({ + imageID: 'restored', + firstPoint: [0, 0, 0], + secondPoint: [3, 0, 0], + }); + expect(restored.appearanceOfTool(restored.rulers[0].id)).toMatchObject({ + name: 'Original', + color: [0, 0, 255, 255], + }); + } +); diff --git a/src/io/state-file/serialize.ts b/src/io/state-file/serialize.ts index 478f0a88c..89b998bcb 100644 --- a/src/io/state-file/serialize.ts +++ b/src/io/state-file/serialize.ts @@ -292,8 +292,8 @@ const serializingStoreHooks = [ useViewStore, useViewConfigStore, useSegmentStore, - useSegmentationStore, useToolStore, + useSegmentationStore, useLayersStore, ]; @@ -340,10 +340,11 @@ export async function serialize( manifest, }; - // Writers run in order: later ones read manifest entries the earlier ones - // wrote. + // Related synchronous snapshots stay together before mask encoding yields. + // Later writers can read manifest entries the earlier ones wrote. for (const write of dependencies.writers) { - await write(stateFile); + const pending = write(stateFile); + if (pending) await pending; } const repaired = normalizeManifest(manifest, zip); if (repaired.omitted.length > 0) { From cab3a25ce023ffbe3b613ed239c2046371baa84a Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 29 Sep 2026 19:53:04 -0400 Subject: [PATCH 37/39] fix(segmentation): prepare all import components before creating masks --- .../__tests__/applyResults.segments.spec.ts | 62 ++++++++++++------- src/segmentation/io/import.ts | 24 ++++--- 2 files changed, 54 insertions(+), 32 deletions(-) diff --git a/src/processing/__tests__/applyResults.segments.spec.ts b/src/processing/__tests__/applyResults.segments.spec.ts index d33007f89..f34641b45 100644 --- a/src/processing/__tests__/applyResults.segments.spec.ts +++ b/src/processing/__tests__/applyResults.segments.spec.ts @@ -13,6 +13,7 @@ import { ensureSameSpace } from '@/src/io/resample/resample'; import { useImageCacheStore } from '@/src/store/image-cache'; import { useSegmentStore } from '@/src/segmentation/segments'; import { usePolygonStore } from '@/src/store/tools/polygons'; +import { defer } from '@/src/utils'; import { seatImage, seedVoxel, @@ -205,11 +206,18 @@ describe('processing segment identity', () => { ]); }); - it('imports every component again when a retry follows a partial import', async () => { + it('keeps existing edits and imports each component once after a failed attempt', async () => { + const { maskId } = existingMask('parent-B', 'Existing'); + const entered = defer(); + const released = defer(); const resample = vi .fn(ensureSameSpace) .mockImplementationOnce(ensureSameSpace) - .mockRejectedValueOnce(new Error('Resample failed')); + .mockImplementationOnce(async () => { + entered.resolve(); + await released.promise; + throw new Error('Resample failed'); + }); const segmentWriter = { ...appApplyDependencies().segmentWriter, convertImageToLabelmap: ( @@ -235,35 +243,45 @@ describe('processing segment identity', () => { ); return id; }; - - expect( - await importResult(undefined, { + const attempt = () => + importResult([{ value: 1, name: 'Liver', color: blue }], { importVolume: importTwoComponents, segmentWriter, - }) - ).toMatchObject({ status: 'failed' }); - expect(savedMasks(await save(), 'parent-B')).toMatchObject([ + }); + + const importing = attempt(); + await entered.promise; + seedVoxel(maskId, [2, 1, 1]); + released.resolve(); + expect(await importing).toMatchObject({ status: 'failed' }); + const failed = await save(); + const existing = { + id: maskId, + segment: { name: 'Existing', color: red }, + extent: [1, 2, 1, 1, 1, 1], + source: undefined, + artifact: { values: [1, 1] }, + }; + expect(savedMasks(failed, 'parent-B')).toMatchObject([existing]); + expect(failed.manifest.segments).toHaveLength(1); + + expect(await attempt()).toEqual({ status: 'applied' }); + const retried = await save(); + expect(savedMasks(retried, 'parent-B')).toMatchObject([ + existing, { + segment: { name: 'Liver', color: blue }, extent: [1, 1, 1, 1, 1, 1], - source: undefined, + source, artifact: { values: [1] }, }, - ]); - expect( - await importResult(undefined, { - importVolume: importTwoComponents, - segmentWriter, - }) - ).toEqual({ status: 'applied' }); - expect(imports).toBe(2); - expect(savedMasks(await save(), 'parent-B')).toMatchObject([ { - extent: [1, 1, 1, 1, 1, 1], - source: undefined, + segment: { name: 'Liver (2)', color: blue }, + extent: [2, 2, 2, 2, 2, 2], + source, artifact: { values: [1] }, }, - { extent: [1, 1, 1, 1, 1, 1], source, artifact: { values: [1] } }, - { extent: [2, 2, 2, 2, 2, 2], source, artifact: { values: [1] } }, ]); + expect(retried.manifest.segments).toHaveLength(3); }); }); diff --git a/src/segmentation/io/import.ts b/src/segmentation/io/import.ts index cc70b1607..f2e71be12 100644 --- a/src/segmentation/io/import.ts +++ b/src/segmentation/io/import.ts @@ -344,9 +344,10 @@ export async function importLabelmapImage( const images = extractEachComponent(childImage); - // Sequential, not fanned out: the splits share one segmentation, and each - // binds its segments against the ones already in it. - const created: ImportedSegment[][] = []; + const prepared: Array<{ + labelmap: vtkLabelMap; + descriptors: LabelmapSegment[]; + }> = []; const cache = useImageCacheStore(); for (const [component, image] of images.entries()) { const matchingParentSpace = await (hooks.resample ?? ensureSameSpace)( @@ -367,12 +368,15 @@ export async function importLabelmapImage( if (!cache.imageById[imageID]) { throw new Error('Labelmap image is no longer loaded'); } - created.push( - hooks.split(labelmapImage, descriptors).map((maskId, index) => ({ - sourceValue: descriptors[index].value, - maskId, - })) - ); + prepared.push({ labelmap: labelmapImage, descriptors }); } - return created; + + // Finish fallible asynchronous work before creating any masks. The splits + // commit in order without yielding, so each sees the preceding bindings. + return prepared.map(({ labelmap, descriptors }): ImportedSegment[] => + hooks.split(labelmap, descriptors).map((maskId, index) => ({ + sourceValue: descriptors[index].value, + maskId, + })) + ); } From a11279fabba536e869be893123ba353b29645adf Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 29 Sep 2026 19:57:23 -0400 Subject: [PATCH 38/39] fix(processing): refresh input bindings when retrying cached results --- .../applyResults.annotations.spec.ts | 140 +++++++++++++++++- src/processing/engine/jobHistory.ts | 33 ++++- src/processing/store.ts | 46 ++---- 3 files changed, 185 insertions(+), 34 deletions(-) diff --git a/src/processing/__tests__/applyResults.annotations.spec.ts b/src/processing/__tests__/applyResults.annotations.spec.ts index e3cf796bd..a2e969ace 100644 --- a/src/processing/__tests__/applyResults.annotations.spec.ts +++ b/src/processing/__tests__/applyResults.annotations.spec.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createPinia, setActivePinia } from 'pinia'; import { nextTick } from 'vue'; import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; @@ -7,6 +7,7 @@ import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; import { appApplyDependencies, applyIntent, + autoLoadProcessingResults, } from '@/src/processing/applyResults'; import type { ProcessingResult, @@ -14,10 +15,18 @@ import type { } from '@/src/processing/types'; import { useImageCacheStore } from '@/src/store/image-cache'; import { useDICOMStore } from '@/src/store/datasets-dicom'; +import { useImageStore } from '@/src/store/datasets-images'; import { useSegmentationStore } from '@/src/segmentation/store'; import { useSegmentStore } from '@/src/segmentation/segments'; import { cssColorToRGBA, rgbaToCssColor } from '@/src/segmentation/color'; -import { messageTitles } from '@/src/components/__tests__/messageDisplay'; +import { + messageDetails, + messageTitles, +} from '@/src/components/__tests__/messageDisplay'; +import { useProcessingJobsStore } from '@/src/processing/store'; +import { useMessageStore } from '@/src/store/messages'; +import { seatDataSource } from '@/src/store/__tests__/datasetFixtures'; +import { makeFakeProvider, registerFake } from './fakeProvider'; import { savedMasks, serializeAnnotations, @@ -811,3 +820,130 @@ describe('applyIntent — add-annotations', () => { }); }); }); + +describe('retrying annotations after loading the input image', () => { + const inputUri = '/job-input.nrrd'; + const jobRef = { providerId: 'provider-1', jobId: 'job-1' }; + const detail = { + jobId: jobRef.jobId, + log: [], + parameters: { inputVolume: { type: 'image', uris: [inputUri] } }, + }; + + const adoptedJob = async (reloaded = false) => { + serveRulers(annotationsFile()); + const provider = makeFakeProvider( + { + id: jobRef.providerId, + label: 'Analysis', + baseUrl: '/', + jobsBaseUrl: '/', + }, + { + listJobHistory: vi.fn().mockResolvedValue({ + jobs: [ + { + jobId: jobRef.jobId, + taskId: 'task-1', + taskTitle: 'Annotate', + createdBy: { id: 'user-1', name: 'User' }, + createdAt: '2026-07-03T19:00:00Z', + state: 'success', + resultState: 'ready', + }, + ], + nextCursor: null, + }), + getJobHistoryDetail: vi.fn().mockResolvedValue(detail), + getResults: vi + .fn() + .mockResolvedValueOnce({ results: [intent()], missing: 0 }) + .mockRejectedValue(new Error('Results requested more than once')), + } + ); + const jobs = useProcessingJobsStore(); + registerFake(jobs, provider); + await jobs.adoptJobHistory(); + const loadAndApply = async () => { + await jobs.loadJobResults(jobRef); + await jobs.applyJobResults(jobRef, (pending, submitted) => + autoLoadProcessingResults(pending, submitted, { + ...appApplyDependencies(), + fetchResult: results.fetchResult, + }) + ); + }; + if (reloaded) { + registerInput(IMAGE_ID); + await jobs.loadJobResults(jobRef); + useImageStore().deleteData(IMAGE_ID); + await nextTick(); + } + await loadAndApply(); + expect(messageDetails('Failed to apply out.annotations.json')).toContain( + "Load the job's input image" + ); + expect(toolCounts()).toEqual({ rulers: 0, rectangles: 0, polygons: 0 }); + useMessageStore().clearAll(); + return { jobs, provider, loadAndApply }; + }; + + const registerInput = (id: string) => { + useImageStore().addVTKImageData( + 'CT', + useImageCacheStore().getVtkImageData(id)!, + { id } + ); + seatDataSource(id, { type: 'uri', uri: inputUri, name: 'job-input.nrrd' }); + }; + + const loadInput = () => { + seatImage('reopened'); + registerInput('reopened'); + }; + + it.each([false, true])( + 'applies the cached result once with its appearance after reloading=%s', + async (reloaded) => { + const { loadAndApply } = await adoptedJob(reloaded); + loadInput(); + await loadAndApply(); + await loadAndApply(); + + expect(messageTitles()).toEqual([]); + expect(toolCounts()).toEqual({ rulers: 1, rectangles: 0, polygons: 0 }); + const saved = serializeAnnotations(); + const ruler = saved.tools!.rulers!.tools[0]; + expect(ruler).toMatchObject({ + imageID: 'reopened', + firstPoint: [1, 1, 5], + secondPoint: [4, 4, 5], + name: 'Long axis', + source, + }); + expect( + saved.segments?.find(({ id }) => id === ruler.segmentId) + ).toMatchObject({ name: 'roi', color: [255, 0, 0, 255] }); + } + ); + + it('does not apply a cached result after deletion during parent lookup', async () => { + const { jobs, provider, loadAndApply } = await adoptedJob(); + loadInput(); + const entered = defer(); + const released = defer(); + provider.getJobHistoryDetail.mockImplementationOnce(() => { + entered.resolve(); + return released.promise; + }); + + const loading = loadAndApply(); + await entered.promise; + await jobs.deleteJob(jobRef); + released.resolve(detail); + await loading; + + expect(messageTitles()).toEqual([]); + expect(toolCounts()).toEqual({ rulers: 0, rectangles: 0, polygons: 0 }); + }); +}); diff --git a/src/processing/engine/jobHistory.ts b/src/processing/engine/jobHistory.ts index b7a8d05cb..994bd865a 100644 --- a/src/processing/engine/jobHistory.ts +++ b/src/processing/engine/jobHistory.ts @@ -1,10 +1,41 @@ -import type { JobState, JobHistorySummary } from '@/backend-contract'; +import type { + JobState, + JobHistorySummary, + JobHistoryDetail, +} from '@/backend-contract'; +import { + inputValueSchema, + TYPE_TAG_ANNOTATIONS, + TYPE_TAG_LABELMAP, +} from '@/backend-contract'; import type { ProcessingJobStatus, SubmittedJobContext, } from '@/src/processing/types'; import { jobKey } from '@/src/processing/types'; +// Staged inputs derive FROM the scene rather than naming a dataset, so they are +// never parent-image candidates. Excluding them by tag keeps the open image +// vocabulary open: anything else that carries provenance URIs counts. +const STAGED_INPUT_TYPES: ReadonlySet = new Set([ + TYPE_TAG_LABELMAP, + TYPE_TAG_ANNOTATIONS, +]); + +function isImageInputValue(v: unknown): v is { type: string; uris: string[] } { + const parsed = inputValueSchema.safeParse(v); + return parsed.success && !STAGED_INPUT_TYPES.has(parsed.data.type); +} + +// Anything but exactly one image input is ambiguous; never guess a parent to +// attach results to. +export const selectParentImageUris = ( + parameters: JobHistoryDetail['parameters'] +): string[] | undefined => { + const imageInputs = Object.values(parameters ?? {}).filter(isImageInputValue); + return imageInputs.length === 1 ? imageInputs[0].uris : undefined; +}; + // Rows key on (providerId, jobId) because two providers may share a raw jobId. export type TrackedJobHistorySummary = JobHistorySummary & { providerId: string; diff --git a/src/processing/store.ts b/src/processing/store.ts index 4f46c4f0e..0c6dacae6 100644 --- a/src/processing/store.ts +++ b/src/processing/store.ts @@ -5,11 +5,6 @@ import { computed, reactive, ref } from 'vue'; import deepEqual from 'fast-deep-equal'; import type { JobHistoryDetail, JobHistorySummary } from '@/backend-contract'; -import { - inputValueSchema, - TYPE_TAG_ANNOTATIONS, - TYPE_TAG_LABELMAP, -} from '@/backend-contract'; import { collectProvenanceUris } from '@/src/processing/engine/mintInput'; import type { ProcessingJobStatus, @@ -26,7 +21,10 @@ import { missingJobErrorDetails, } from '@/src/processing/types'; import type { TrackedJobHistorySummary } from '@/src/processing/engine/jobHistory'; -import { selectJobHistoryRows } from '@/src/processing/engine/jobHistory'; +import { + selectJobHistoryRows, + selectParentImageUris, +} from '@/src/processing/engine/jobHistory'; import { autoLoadProcessingResults } from '@/src/processing/applyResults'; import { useMessageStore } from '@/src/store/messages'; import { useDatasetStore } from '@/src/store/datasets'; @@ -37,14 +35,6 @@ export const MAX_POLL_RETRIES = 4; export const MAX_POLL_BACKOFF_MS = 30000; export const MAX_JOB_HISTORY_PAGES = 1000; -// Staged inputs derive FROM the scene rather than naming a dataset, so they are -// never parent-image candidates. Excluding them by tag keeps the open image -// vocabulary open: anything else that carries provenance URIs counts. -const STAGED_INPUT_TYPES: ReadonlySet = new Set([ - TYPE_TAG_LABELMAP, - TYPE_TAG_ANNOTATIONS, -]); - const completionReady = (status: ProcessingJobStatus): boolean => isTerminalJobState(status.state); @@ -465,15 +455,6 @@ export const useProcessingJobsStore = defineStore('processingJobs', () => { : context; } - // An adopted job's persisted image input carries the parent's provenance URIs, - // so the parent can be re-identified among the loaded datasets. - function isImageInputValue( - v: unknown - ): v is { type: string; uris: string[] } { - const parsed = inputValueSchema.safeParse(v); - return parsed.success && !STAGED_INPUT_TYPES.has(parsed.data.type); - } - // Order-insensitive: a re-loaded dataset's provenance walk need not enumerate // in submit order. function sameUriSet(a: string[], b: ReadonlySet): boolean { @@ -500,13 +481,8 @@ export const useProcessingJobsStore = defineStore('processingJobs', () => { } catch { return undefined; // best-effort: the open-as-dataset fallback still works } - const imageInputs = Object.values(detail?.parameters ?? {}).filter( - isImageInputValue - ); - // Anything but exactly one image input is ambiguous; never guess a parent to - // attach results to. - if (imageInputs.length !== 1) return undefined; - return datasetIdForUris(imageInputs[0].uris); + const uris = selectParentImageUris(detail?.parameters); + return uris === undefined ? undefined : datasetIdForUris(uris); } // Rebuild an adopted job's missing parent id so a labelmap result attaches @@ -746,7 +722,6 @@ export const useProcessingJobsStore = defineStore('processingJobs', () => { // path, so its results are not in `jobResults`. async function loadJobResults(jobRef: TrackedJobRef) { const key = jobKey(jobRef); - if (jobResults.has(key)) return; const context = submittedContexts.get(key); if (!context) return; // A delete landing while this fetch is in flight must not commit results or @@ -754,6 +729,15 @@ export const useProcessingJobsStore = defineStore('processingJobs', () => { const gen = jobGenerations.get(key); try { const provider = await getProvider(jobRef.providerId); + if (jobResults.has(key)) { + await ensureAdoptedParentId( + provider, + key, + gen, + contextForAutoLoad(context) + ); + return; + } await fetchAndRecordResults(provider, jobRef.jobId, key, gen, context); } catch (err) { if (expireSessionIf(err)) return; From eceda37d583ebf0946f7dc502baacee33a22eb61 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Tue, 29 Sep 2026 20:10:06 -0400 Subject: [PATCH 39/39] docs: keep state file guidance focused on saving and loading --- docs/quick_start_guide.md | 2 +- docs/state_files.md | 175 ++------------------------------------ 2 files changed, 10 insertions(+), 167 deletions(-) diff --git a/docs/quick_start_guide.md b/docs/quick_start_guide.md index e79cc31ea..c4baa90de 100644 --- a/docs/quick_start_guide.md +++ b/docs/quick_start_guide.md @@ -66,4 +66,4 @@ VolView reads the DICOM tags of your data to determine appropriate preset parame ## 4. Saving / loading state -Once you have made the measures and generated the visualizations that you want to store to recall later or share with others, use the icons at the top of the toolbar to Load and Save state files. For more information on the json format of these state files and how they can be used to integrate VolView with workflows and other services, see [State Files](state_files.html). +Use the Load and Save icons in the toolbar to reopen or save your images, annotations, and view settings. See [State Files](./state_files.md) for details on saving and sharing your work. diff --git a/docs/state_files.md b/docs/state_files.md index f6c19772b..050ccc6a5 100644 --- a/docs/state_files.md +++ b/docs/state_files.md @@ -1,174 +1,17 @@ # State Files -VolView state files save your scene configuration: annotations, camera positions, colormaps, layouts, and more. There are two formats: +State files save your scene so you can return to your work later or share it with others. They preserve annotations, segmentations, camera positions, colormaps, layouts, and other view settings. -## Zip State Files (`*.volview.zip`) +## Saving a Scene -Save by clicking the "Disk" icon in the toolbar. This embeds your image data that was loaded from local files alongside the application state. Useful for sharing annotations with collaborators. +Click the **Save** (disk) icon in the toolbar to download a `*.volview.zip` file. Images loaded from local files are included alongside the saved scene. -## Sparse Manifest Files (`*.volview.json`) +## Linked State Files -JSON files that reference remote data via URIs instead of embedding it. Useful for: +A `*.volview.json` file references data hosted on a server instead of including it in the file. This lets workflows and external applications open a prepared scene without copying large datasets. Anyone opening the scene needs access to the referenced data. -- Linking to data hosted on servers -- Sharing annotations without duplicating large datasets -- Integrating with external systems (AI pipelines, access control, etc.) +## Loading a Saved Scene -## Manifest (version 7.0.0) - -Both formats carry this manifest. A segmentation owns one image's segment masks; -labelmaps encode those masks for storage or interchange. The top-level `segments` -list holds the identities the masks paint and the tools draw with, each mask names -the segment it carries voxels for, and `order` lists the masks of that -segmentation. A mask `order` leaves out is skipped on restore. `selectedSegment` -names the segment that was selected. - -A segment has a `name` and an RGBA `color`. It may also state `visible` and -`locked`, which default to `true` and `false`, and `fillOpacity`, `outlineOpacity` -and `strokeWidth`, which fall back to the app defaults. A segmentation may state -the `fillOpacity`, `outlineOpacity` and `outlineThickness` of the image's Display -settings. Rulers, rectangles and polygons under `tools` name their segment with -`segmentId`. - -A segment the file lists takes over an existing segment of the same name that -holds no masks or shapes yet, such as one a configuration defined. If that -segment already holds content, the restored one gets a numbered name like -`Tumor (2)`. - -A mask saved into a zip names its own archive entry with `path`. A sparse -manifest instead points at a whole label volume: `segmentationArtifacts` names -that volume, its `dataSourceId` says where the bytes come from, and each mask -whose `artifactId` points at it is filled from the `sourceValue` it declares, 1 -when omitted. -An artifact is a single-component label volume; one with several components is -skipped on restore. Extents are placeholders until the volume is read. A value -no mask reads, including every value of a volume no mask points at, gets its -segment the way loading that volume on its own would: it joins an existing -segment of its name, even one holding content on another image, unless that -segment already has a mask on this image, in which case the value gets a new -segment with a numbered name. - -```json -{ - "version": "7.0.0", - "dataSources": [ - { "id": 0, "type": "uri", "uri": "https://example.com/scan.zip" }, - { "id": 1, "type": "uri", "uri": "https://example.com/segmentation.nii.gz" } - ], - "segments": [ - { - "id": "segment-tumor", - "name": "Tumor", - "color": [255, 0, 0, 255], - "visible": true, - "locked": false - } - ], - "segmentations": [ - { - "id": "segmentation-0", - "name": "Tumor Segmentation", - "parentImage": "0", - "masks": [ - { - "id": "mask-tumor", - "segmentId": "segment-tumor", - "representations": { - "labelmap": { - "artifactId": "labelmap-1", - "sourceValue": 1, - "extent": [0, -1, 0, -1, 0, -1] - } - } - } - ], - "order": ["mask-tumor"] - } - ], - "segmentationArtifacts": [ - { - "id": "labelmap-1", - "parentImage": "0", - "name": "Tumor Segmentation", - "dataSourceId": 1 - } - ], - "selectedSegment": "segment-tumor", - "tools": { - "rectangles": { - "tools": [ - { - "imageID": "0", - "frameOfReference": { - "planeNormal": [0, 0, 1], - "planeOrigin": [0, 0, 50] - }, - "slice": 50, - "firstPoint": [-20, -20, 50], - "secondPoint": [20, 20, 50], - "segmentId": "segment-tumor" - } - ] - } - } -} -``` - -## Legacy 6.x manifest (still read on import) - -Manifests up to version 6.4.0, such as this 6.2.0 one, are migrated on load: the -historical `segmentGroups` field becomes the current segmentation model, and the -per-tool `labels` records become segments the tools reference by id. Nothing -writes this form any more. - -```json -{ - "version": "6.2.0", - "dataSources": [ - { "id": 0, "type": "uri", "uri": "https://example.com/scan.zip" }, - { "id": 1, "type": "uri", "uri": "https://example.com/segmentation.nii.gz" } - ], - "segmentGroups": [ - { - "id": "seg-1", - "dataSourceId": 1, - "metadata": { - "name": "Tumor Segmentation", - "parentImage": "0", - "segments": { - "order": [1], - "byValue": { - "1": { "value": 1, "name": "Tumor", "color": [255, 0, 0, 255] } - } - } - } - } - ], - "tools": { - "rectangles": { - "tools": [ - { - "imageID": "0", - "frameOfReference": { - "planeNormal": [0, 0, 1], - "planeOrigin": [0, 0, 50] - }, - "slice": 50, - "firstPoint": [-20, -20, 50], - "secondPoint": [20, 20, 50], - "label": "lesion" - } - ], - "labels": { - "lesion": { "color": "red" } - } - } - } -} -``` - -## Loading State Files - -- **Drag and drop** onto VolView -- **File browser** via the "Folder" icon below the save button -- **URL parameter**: `?urls=[https://example.com/session.volview.json]` +- Drag and drop a state file onto VolView. +- Click the **Load** (folder) icon in the toolbar and select a state file. +- Open a link that includes a state file in the `urls` parameter, such as `?urls=[https://example.com/session.volview.json]`.