diff --git a/eslint.config.js b/eslint.config.js
index e16734f65..41057a805 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -4,6 +4,86 @@ import tseslint from 'typescript-eslint';
import eslintConfigPrettier from 'eslint-config-prettier';
import globals from 'globals';
+// ---------------------------------------------------------------------------
+// Feature layering boundaries.
+//
+// Enforced with the built-in `no-restricted-imports` — eslint-plugin-import is
+// not a dependency of this repo, and the built-in rule expresses the same
+// zones with no new dependency. Two zones per feature:
+// 1. Code OUTSIDE the feature directory may reach it ONLY through its public
+// surface `@/src/
` (the index), never a deep path.
+// 2. The feature's pure layer may not import stores, components, or the
+// upper feature modules, and stays framework-free (no pinia, no vue) —
+// dependencies point downward only.
+//
+// Flat config replaces a rule's options wholesale when a later block matches
+// the same file, so all features are generated together: each block carries
+// the full pattern set its files need, and no block silently erases another
+// feature's boundary.
+// ---------------------------------------------------------------------------
+// `pure.upperModules` is a hand-maintained list of the feature's non-pure
+// modules: a new one has to be added here or the pure layer may import it.
+const featureBoundaries = (features) => {
+ const publicSurface = ({ dir }) => ({
+ group: [`@/src/${dir}/*`, `@/src/${dir}/*/**`, `!@/src/${dir}/index`],
+ message: `Import the ${dir} feature only from its public surface \`@/src/${dir}\` (src/${dir}/index.ts). A deep import bypasses the feature boundary.`,
+ });
+ const otherSurfaces = (feature) =>
+ features.filter((other) => other !== feature).map(publicSurface);
+
+ return [
+ {
+ files: ['src/**/*.{js,ts,vue}'],
+ ignores: features.map(({ dir }) => `src/${dir}/**`),
+ rules: {
+ 'no-restricted-imports': [
+ 'error',
+ { patterns: features.map(publicSurface) },
+ ],
+ },
+ },
+ ...features.map((feature) => ({
+ files: [`src/${feature.dir}/**/*.{js,ts,vue}`],
+ rules: {
+ 'no-restricted-imports': [
+ 'error',
+ { patterns: otherSurfaces(feature) },
+ ],
+ },
+ })),
+ ...features.map((feature) => ({
+ files: feature.pure.files,
+ ignores: ['**/__tests__/**'],
+ rules: {
+ 'no-restricted-imports': [
+ 'error',
+ {
+ paths: ['pinia', 'vue'].map((name) => ({
+ name,
+ message: `The ${feature.dir} pure layer must stay framework-free — no ${name}.`,
+ })),
+ patterns: [
+ {
+ group: [
+ ...feature.pure.upperModules.flatMap((mod) => [
+ `@/src/${feature.dir}/${mod}`,
+ `./${mod}`,
+ `../${mod}`,
+ ]),
+ '@/src/store/**',
+ '@/src/components/**',
+ ],
+ message: `The ${feature.dir} pure layer must not import stores, components, or upper feature modules — dependencies point downward only.`,
+ },
+ ...otherSurfaces(feature),
+ ],
+ },
+ ],
+ },
+ })),
+ ];
+};
+
export default tseslint.config(
{
ignores: [
@@ -89,90 +169,34 @@ export default tseslint.config(
],
},
},
- // ---------------------------------------------------------------------------
- // Processing feature layering boundaries.
- //
- // Enforced with the built-in `no-restricted-imports` — eslint-plugin-import is
- // not a dependency of this repo, and the built-in rule expresses the same
- // zones with no new dependency. Two rules:
- // 1. Code OUTSIDE `src/processing/` may reach the feature ONLY through its
- // public surface `@/src/processing` (the index), never a deep path.
- // 2. The feature's pure layer (`engine/**`, `types.ts`, `config.ts`) may not
- // import stores, components, or the upper feature modules, and stays
- // framework-free (no pinia, no vue) — dependencies point downward only.
- // ---------------------------------------------------------------------------
- {
- files: ['src/**/*.{js,ts,vue}'],
- ignores: ['src/processing/**'],
- rules: {
- 'no-restricted-imports': [
- 'error',
- {
- patterns: [
- {
- group: [
- '@/src/processing/*',
- '@/src/processing/*/**',
- '!@/src/processing/index',
- ],
- message:
- 'Import the processing feature only from its public surface `@/src/processing` (src/processing/index.ts). A deep import bypasses the feature boundary.',
- },
- ],
- },
- ],
+ ...featureBoundaries([
+ {
+ dir: 'processing',
+ pure: {
+ files: [
+ 'src/processing/engine/**/*.{js,ts}',
+ 'src/processing/types.ts',
+ 'src/processing/config.ts',
+ ],
+ upperModules: [
+ 'store',
+ 'applyResults',
+ 'jobResultReview',
+ 'index',
+ 'components/**',
+ ],
+ },
},
- },
- {
- files: [
- 'src/processing/engine/**/*.{js,ts}',
- 'src/processing/types.ts',
- 'src/processing/config.ts',
- ],
- ignores: ['**/__tests__/**'],
- rules: {
- 'no-restricted-imports': [
- 'error',
- {
- paths: [
- {
- name: 'pinia',
- message:
- 'The processing pure layer (engine/types/config) must stay framework-free — no pinia.',
- },
- {
- name: 'vue',
- message:
- 'The processing pure layer (engine/types/config) must stay framework-free — no vue.',
- },
- ],
- patterns: [
- {
- group: [
- '@/src/processing/store',
- '@/src/processing/applyResults',
- '@/src/processing/jobResultReview',
- '@/src/processing/index',
- '@/src/processing/components/**',
- '@/src/store/**',
- '@/src/components/**',
- './store',
- './applyResults',
- './jobResultReview',
- './index',
- '../store',
- '../applyResults',
- '../jobResultReview',
- '../index',
- '../components/**',
- ],
- message:
- 'The processing pure layer (engine/types/config) must not import stores, components, or upper feature modules — dependencies point downward only.',
- },
- ],
- },
- ],
+ {
+ dir: 'referenceLines',
+ pure: {
+ files: [
+ 'src/referenceLines/geometry.ts',
+ 'src/referenceLines/crossings.ts',
+ ],
+ upperModules: ['store', 'index', 'useReferenceLines', 'components/**'],
+ },
},
- },
+ ]),
eslintConfigPrettier
);
diff --git a/src/components/Settings.vue b/src/components/Settings.vue
index 48c9cd686..94ca96577 100644
--- a/src/components/Settings.vue
+++ b/src/components/Settings.vue
@@ -26,6 +26,15 @@
hide-details
>
+
+
{
@@ -90,6 +103,7 @@ export default defineComponent({
errorReportingConfigured,
openKeyboardShortcuts,
disableCameraAutoReset,
+ referenceLinesEnabled,
};
},
components: {
diff --git a/src/components/SliceViewer.vue b/src/components/SliceViewer.vue
index 132f61ad0..ebc88d5da 100644
--- a/src/components/SliceViewer.vue
+++ b/src/components/SliceViewer.vue
@@ -116,7 +116,13 @@
>
-
+
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/components/tools/crosshairs/CrosshairsTool.vue b/src/components/tools/crosshairs/CrosshairsTool.vue
deleted file mode 100644
index 4f1b73778..000000000
--- a/src/components/tools/crosshairs/CrosshairsTool.vue
+++ /dev/null
@@ -1,81 +0,0 @@
-
-
-
-
-
-
-
-
-
-
diff --git a/src/components/tools/crosshairs/CrosshairsWidget2D.vue b/src/components/tools/crosshairs/CrosshairsWidget2D.vue
index 435c37149..7294c1a76 100644
--- a/src/components/tools/crosshairs/CrosshairsWidget2D.vue
+++ b/src/components/tools/crosshairs/CrosshairsWidget2D.vue
@@ -18,6 +18,7 @@ import { useCrosshairsToolStore } from '@/src/store/tools/crosshairs';
import { Maybe } from '@/src/types';
import { VtkViewContext } from '@/src/components/vtk/context';
import { useSliceInfo } from '@/src/composables/useSliceInfo';
+import { onVTKEvent } from '@/src/composables/onVTKEvent';
export default defineComponent({
name: 'CrosshairsWidget2D',
@@ -53,6 +54,16 @@ export default defineComponent({
view.widgetManager.removeWidget(factory);
});
+ // --- interaction --- //
+
+ // Only the widget the drag happened in fires this, which is what tells the
+ // store which image the picked point belongs to.
+ const handle = factory.getWidgetState().getHandle();
+ onVTKEvent(widget, 'onInteractionEvent', () => {
+ const origin = handle.getOrigin();
+ if (origin) crosshairsStore.setPosition(origin, viewId.value);
+ });
+
// --- manipulator --- //
const manipulator = vtkPlaneManipulator.newInstance();
diff --git a/src/constants.ts b/src/constants.ts
index 33ed552e7..6752cc03c 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -10,6 +10,9 @@ export const DarkTheme = 'kw-dark';
export const LightTheme = 'kw-light';
export const DefaultTheme = DarkTheme;
+// The slice rendering inflates the image box by half a voxel on every face.
+export const IMAGE_BOX_INFLATION = 0.5;
+
export const Messages = {
WebGLLost: {
title: 'Viewer Error',
diff --git a/src/core/views/__tests__/effectiveView.spec.ts b/src/core/views/__tests__/effectiveView.spec.ts
index e55d35e54..48ca990df 100644
--- a/src/core/views/__tests__/effectiveView.spec.ts
+++ b/src/core/views/__tests__/effectiveView.spec.ts
@@ -1,6 +1,10 @@
import { describe, it, expect, vi } from 'vitest';
-import { computeEffectiveView } from '@/src/core/views/effectiveView';
+import {
+ computeEffectiveView,
+ volume2DViewsOfImage,
+} from '@/src/core/views/effectiveView';
import type {
+ ViewInfo,
ViewInfo2D,
ViewInfo3D,
ViewInfoOblique,
@@ -70,3 +74,62 @@ describe('computeEffectiveView', () => {
expect(eff.kind).toBe('cine');
});
});
+
+const slotFor = (id: string, orientation: string, dataID: string | null) =>
+ ({
+ id,
+ type: '2D',
+ dataID,
+ name: orientation,
+ options: { orientation },
+ }) as ViewInfo;
+
+describe('volume2DViewsOfImage', () => {
+ it('keeps every 2D view of the image, with its axis', () => {
+ const views = [
+ slotFor('axial', 'Axial', 'image-1'),
+ slotFor('sagittal', 'Sagittal', 'image-1'),
+ slotFor('coronal', 'Coronal', 'image-1'),
+ ];
+
+ expect(volume2DViewsOfImage('image-1', views)).toEqual([
+ { viewId: 'axial', axis: 'Axial' },
+ { viewId: 'sagittal', axis: 'Sagittal' },
+ { viewId: 'coronal', axis: 'Coronal' },
+ ]);
+ });
+
+ it('keeps two views sharing an axis', () => {
+ const views = [
+ slotFor('axial-1', 'Axial', 'image-1'),
+ slotFor('axial-2', 'Axial', 'image-1'),
+ ];
+
+ expect(volume2DViewsOfImage('image-1', views)).toEqual([
+ { viewId: 'axial-1', axis: 'Axial' },
+ { viewId: 'axial-2', axis: 'Axial' },
+ ]);
+ });
+
+ it('drops views showing a different image', () => {
+ const views = [
+ slotFor('axial', 'Axial', 'image-1'),
+ slotFor('sagittal', 'Sagittal', 'image-2'),
+ ];
+
+ expect(volume2DViewsOfImage('image-1', views)).toEqual([
+ { viewId: 'axial', axis: 'Axial' },
+ ]);
+ });
+
+ it('drops empty, 3D, oblique and cine views', () => {
+ const views = [
+ slotFor('empty', 'Sagittal', null),
+ { ...view3D, dataID: 'image-1' },
+ { ...viewOblique, dataID: 'image-1' },
+ slotFor('cine', 'Coronal', 'cine-image'),
+ ];
+
+ expect(volume2DViewsOfImage('image-1', views)).toEqual([]);
+ });
+});
diff --git a/src/core/views/effectiveView.ts b/src/core/views/effectiveView.ts
index 48e2adfa0..8b8ca2551 100644
--- a/src/core/views/effectiveView.ts
+++ b/src/core/views/effectiveView.ts
@@ -41,6 +41,19 @@ export function computeEffectiveView(
return { kind: 'oblique', viewInfo, renderDataID: dataID };
}
+/**
+ * The slicing (volume2D) views among `views` that render `imageID`, with
+ * their slicing axes.
+ */
+export function volume2DViewsOfImage(imageID: string, views: ViewInfo[]) {
+ return views.flatMap((view) => {
+ const effective = computeEffectiveView(view, view.dataID);
+ if (effective.kind !== 'volume2D') return [];
+ if (effective.renderDataID !== imageID) return [];
+ return [{ viewId: view.id, axis: effective.axis }];
+ });
+}
+
export function getEffectiveView(viewID: Maybe): EffectiveView | null {
if (!viewID) return null;
const view = useViewStore().getView(viewID);
diff --git a/src/io/state-file/schema.ts b/src/io/state-file/schema.ts
index 211168fa0..9be42ba9c 100644
--- a/src/io/state-file/schema.ts
+++ b/src/io/state-file/schema.ts
@@ -407,12 +407,6 @@ const Polygon = annotationTool.extend({
const Polygons = makeToolEntry(Polygon);
-const Crosshairs = z.object({
- position: Vector3.optional(),
-});
-
-export type Crosshairs = z.infer;
-
const ToolsEnumNative = z.nativeEnum(ToolsEnum);
const Paint = z.object({
@@ -435,7 +429,6 @@ const Tools = z.object({
rulers: Rulers.optional(),
rectangles: Rectangles.optional(),
polygons: Polygons.optional(),
- crosshairs: Crosshairs.optional(),
paint: Paint.optional(),
crop: Cropping.optional(),
current: ToolsEnumNative.optional(),
diff --git a/src/io/state-file/serialize.ts b/src/io/state-file/serialize.ts
index 311fbebe5..6f33cfa90 100644
--- a/src/io/state-file/serialize.ts
+++ b/src/io/state-file/serialize.ts
@@ -290,9 +290,6 @@ export async function serialize() {
datasetFilePath: {},
segmentGroups: [],
tools: {
- crosshairs: {
- position: [0, 0, 0],
- },
paint: {
activeSegmentGroupID: null,
activeSegment: null,
diff --git a/src/referenceLines/ReferenceLines.vue b/src/referenceLines/ReferenceLines.vue
new file mode 100644
index 000000000..71255a63d
--- /dev/null
+++ b/src/referenceLines/ReferenceLines.vue
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
diff --git a/src/referenceLines/__tests__/crossings.spec.ts b/src/referenceLines/__tests__/crossings.spec.ts
new file mode 100644
index 000000000..beb9cf5d7
--- /dev/null
+++ b/src/referenceLines/__tests__/crossings.spec.ts
@@ -0,0 +1,146 @@
+import { describe, it, expect } from 'vitest';
+import {
+ splitSegmentsAtCrossings,
+ type ScreenSegment,
+ type SegmentPiece,
+} from '../crossings';
+
+// A 16px gap leaves 8px of clear space either side of a crossing, matching the
+// break the crosshairs overlay used to draw.
+const GAP = 16;
+
+const segment = (
+ id: string,
+ x1: number,
+ y1: number,
+ x2: number,
+ y2: number
+): ScreenSegment => ({ id, x1, y1, x2, y2 });
+
+const piecesOf = (id: string, result: SegmentPiece[]) =>
+ result.filter((piece) => piece.key.startsWith(`${id}:`));
+
+/** Keys must match exactly; coordinates only to within rounding. */
+function expectPieces(
+ actual: SegmentPiece[],
+ expected: Array<[string, number, number, number, number]>
+) {
+ expect(actual.map((piece) => piece.key)).toEqual(
+ expected.map(([key]) => key)
+ );
+ actual.forEach((piece, index) => {
+ const [, x1, y1, x2, y2] = expected[index];
+ expect([piece.x1, piece.y1, piece.x2, piece.y2]).toAlmostEqual([
+ x1,
+ y1,
+ x2,
+ y2,
+ ]);
+ });
+}
+
+describe('splitSegmentsAtCrossings', () => {
+ it('breaks both segments around a crossing', () => {
+ const horizontal = segment('h', 0, 50, 100, 50);
+ const vertical = segment('v', 50, 0, 50, 100);
+
+ const pieces = splitSegmentsAtCrossings([horizontal, vertical], GAP);
+
+ expectPieces(piecesOf('h', pieces), [
+ ['h:0', 0, 50, 42, 50],
+ ['h:1', 58, 50, 100, 50],
+ ]);
+ expectPieces(piecesOf('v', pieces), [
+ ['v:0', 50, 0, 50, 42],
+ ['v:1', 50, 58, 50, 100],
+ ]);
+ });
+
+ it('leaves non-crossing segments whole', () => {
+ const first = segment('a', 0, 10, 100, 10);
+ const second = segment('b', 0, 90, 100, 90);
+
+ const pieces = splitSegmentsAtCrossings([first, second], GAP);
+
+ expectPieces(pieces, [
+ ['a:0', 0, 10, 100, 10],
+ ['b:0', 0, 90, 100, 90],
+ ]);
+ });
+
+ it('ignores crossings that lie beyond either segment', () => {
+ // These would meet at (50, 50) if both were extended.
+ const horizontal = segment('h', 0, 50, 40, 50);
+ const vertical = segment('v', 50, 0, 50, 40);
+
+ const pieces = splitSegmentsAtCrossings([horizontal, vertical], GAP);
+
+ expectPieces(pieces, [
+ ['h:0', 0, 50, 40, 50],
+ ['v:0', 50, 0, 50, 40],
+ ]);
+ });
+
+ it('gaps a segment once per crossing when several lines cross it', () => {
+ const vertical = segment('v', 50, 0, 50, 100);
+ const lower = segment('h1', 0, 20, 100, 20);
+ const upper = segment('h2', 0, 80, 100, 80);
+
+ const pieces = splitSegmentsAtCrossings([vertical, lower, upper], GAP);
+
+ expectPieces(piecesOf('v', pieces), [
+ ['v:0', 50, 0, 50, 12],
+ ['v:1', 50, 28, 50, 72],
+ ['v:2', 50, 88, 50, 100],
+ ]);
+ // The two parallel peers each keep a single gap.
+ expect(piecesOf('h1', pieces)).toHaveLength(2);
+ expect(piecesOf('h2', pieces)).toHaveLength(2);
+ });
+
+ it('merges gaps from crossings closer together than the gap width', () => {
+ const vertical = segment('v', 50, 0, 50, 100);
+ const first = segment('h1', 0, 48, 100, 48);
+ const second = segment('h2', 0, 52, 100, 52);
+
+ const pieces = splitSegmentsAtCrossings([vertical, first, second], GAP);
+
+ expectPieces(piecesOf('v', pieces), [
+ ['v:0', 50, 0, 50, 40],
+ ['v:1', 50, 60, 50, 100],
+ ]);
+ });
+
+ it('drops the stub when a crossing sits within a gap of the segment end', () => {
+ const horizontal = segment('h', 0, 50, 100, 50);
+ const vertical = segment('v', 4, 0, 4, 100);
+
+ const pieces = splitSegmentsAtCrossings([horizontal, vertical], GAP);
+
+ expectPieces(piecesOf('h', pieces), [['h:0', 12, 50, 100, 50]]);
+ });
+
+ it('drops a segment shorter than its gap', () => {
+ const short = segment('short', 48, 50, 52, 50);
+ const vertical = segment('v', 50, 0, 50, 100);
+
+ const pieces = splitSegmentsAtCrossings([short, vertical], GAP);
+
+ expect(piecesOf('short', pieces)).toEqual([]);
+ expect(piecesOf('v', pieces)).toHaveLength(2);
+ });
+
+ it('drops degenerate segments', () => {
+ const point = segment('p', 50, 50, 50, 50);
+
+ expect(splitSegmentsAtCrossings([point], GAP)).toEqual([]);
+ });
+
+ it('returns a lone segment untouched', () => {
+ const only = segment('only', 0, 0, 100, 100);
+
+ expectPieces(splitSegmentsAtCrossings([only], GAP), [
+ ['only:0', 0, 0, 100, 100],
+ ]);
+ });
+});
diff --git a/src/referenceLines/__tests__/geometry.spec.ts b/src/referenceLines/__tests__/geometry.spec.ts
new file mode 100644
index 000000000..cf6cca8d9
--- /dev/null
+++ b/src/referenceLines/__tests__/geometry.spec.ts
@@ -0,0 +1,233 @@
+import { describe, it, expect } from 'vitest';
+import { mat3, mat4, vec3 } from 'gl-matrix';
+import type { Vector3 } from '@kitware/vtk.js/types';
+import type { ImageMetadata } from '@/src/types/image';
+import type { LPSDirections } from '@/src/types/lps';
+import {
+ computeReferenceLine,
+ slicePlane,
+ type ReferenceLine,
+} from '../geometry';
+
+// --- fixtures ---------------------------------------------------------
+
+const IDENTITY_LPS: LPSDirections = {
+ Left: vec3.fromValues(1, 0, 0),
+ Right: vec3.fromValues(-1, 0, 0),
+ Posterior: vec3.fromValues(0, 1, 0),
+ Anterior: vec3.fromValues(0, -1, 0),
+ Superior: vec3.fromValues(0, 0, 1),
+ Inferior: vec3.fromValues(0, 0, -1),
+ Sagittal: 0,
+ Coronal: 1,
+ Axial: 2,
+};
+
+/**
+ * Builds ImageMetadata from a direction matrix (columns = world direction of
+ * each ijk axis), spacing and origin. Only the fields the geometry reads are
+ * meaningful.
+ */
+function makeMetadata({
+ dimensions = [10, 20, 30] as Vector3,
+ spacing = [1, 1, 1] as Vector3,
+ origin = [0, 0, 0] as Vector3,
+ direction = mat3.create(),
+ lpsOrientation = IDENTITY_LPS,
+}: {
+ dimensions?: Vector3;
+ spacing?: Vector3;
+ origin?: Vector3;
+ direction?: mat3;
+ lpsOrientation?: LPSDirections;
+} = {}): ImageMetadata {
+ const indexToWorld = mat4.create();
+ // column-major: column i is the world vector of one index step along i
+ for (let col = 0; col < 3; col++) {
+ for (let row = 0; row < 3; row++) {
+ indexToWorld[col * 4 + row] = direction[col * 3 + row] * spacing[col];
+ }
+ }
+ indexToWorld[12] = origin[0];
+ indexToWorld[13] = origin[1];
+ indexToWorld[14] = origin[2];
+
+ const worldToIndex = mat4.create();
+ mat4.invert(worldToIndex, indexToWorld);
+
+ return {
+ name: 'test',
+ orientation: direction,
+ lpsOrientation,
+ spacing,
+ origin,
+ dimensions,
+ worldBounds: [0, 0, 0, 0, 0, 0],
+ worldToIndex,
+ indexToWorld,
+ };
+}
+
+/** Endpoints come back in an arbitrary order; compare as an unordered pair. */
+function expectSegment(
+ line: ReferenceLine | null,
+ a: Vector3,
+ b: Vector3
+): void {
+ expect(line).not.toBeNull();
+ const { p1, p2 } = line!;
+ const forward = vec3.distance(p1, a) < 1e-6 && vec3.distance(p2, b) < 1e-6;
+ const backward = vec3.distance(p1, b) < 1e-6 && vec3.distance(p2, a) < 1e-6;
+ expect(
+ forward || backward,
+ `expected segment ${JSON.stringify([a, b])}, got ${JSON.stringify([
+ p1,
+ p2,
+ ])}`
+ ).toBe(true);
+}
+
+// --- slicePlane -------------------------------------------------------
+
+describe('slicePlane', () => {
+ it('builds a unit-normal plane at the slice for an identity image', () => {
+ const metadata = makeMetadata();
+ const plane = slicePlane('Axial', 7, metadata);
+
+ expect(Array.from(plane.normal)).toAlmostEqual([0, 0, 1]);
+ // the plane passes through world z = 7
+ expect(vec3.dot(plane.normal, plane.origin)).toAlmostEqual(7);
+ });
+
+ it('accounts for spacing and a non-identity direction matrix', () => {
+ // 90 degree rotation about z: index i -> world +y, index j -> world -x
+ const direction = mat3.fromValues(0, 1, 0, -1, 0, 0, 0, 0, 1);
+ const metadata = makeMetadata({
+ spacing: [2, 3, 4],
+ origin: [5, -5, 10],
+ direction,
+ });
+
+ const plane = slicePlane('Axial', 3, metadata);
+
+ // Axial is ijk index 2 -> world +z, spacing 4, origin z 10
+ expect(Array.from(plane.normal)).toAlmostEqual([0, 0, 1]);
+ expect(vec3.dot(plane.normal, plane.origin)).toAlmostEqual(10 + 3 * 4);
+
+ const sagittal = slicePlane('Sagittal', 2, metadata);
+ // Sagittal is ijk index 0 -> world +y under this rotation
+ expect(Array.from(sagittal.normal)).toAlmostEqual([0, 1, 0]);
+ expect(vec3.dot(sagittal.normal, sagittal.origin)).toAlmostEqual(
+ -5 + 2 * 2
+ );
+ });
+});
+
+// --- computeReferenceLine ---------------------------------------------
+
+describe('computeReferenceLine', () => {
+ const metadata = makeMetadata({ dimensions: [10, 20, 30] });
+
+ it('returns the clipped intersection of an axial host and a sagittal peer', () => {
+ const host = slicePlane('Axial', 5, metadata);
+ const peer = slicePlane('Sagittal', 3, metadata);
+
+ const line = computeReferenceLine(host, peer, metadata);
+
+ // constant x = 3, constant z = 5, spanning the inflated j extent
+ expectSegment(line, [3, -0.5, 5], [3, 19.5, 5]);
+ });
+
+ it('returns the clipped intersection of a coronal host and an axial peer', () => {
+ const host = slicePlane('Coronal', 8, metadata);
+ const peer = slicePlane('Axial', 12, metadata);
+
+ const line = computeReferenceLine(host, peer, metadata);
+
+ expectSegment(line, [-0.5, 8, 12], [9.5, 8, 12]);
+ });
+
+ it('returns null for parallel planes (same axis peers)', () => {
+ const host = slicePlane('Axial', 5, metadata);
+ const peer = slicePlane('Axial', 17, metadata);
+
+ expect(computeReferenceLine(host, peer, metadata)).toBeNull();
+ });
+
+ it('returns null for coincident planes', () => {
+ const host = slicePlane('Sagittal', 4, metadata);
+ const peer = slicePlane('Sagittal', 4, metadata);
+
+ expect(computeReferenceLine(host, peer, metadata)).toBeNull();
+ });
+
+ it('returns null for near-parallel planes', () => {
+ const host = slicePlane('Axial', 5, metadata);
+ const tilt = 1e-9;
+ const peer = {
+ origin: [0, 0, 10] as Vector3,
+ normal: vec3.normalize(
+ vec3.create(),
+ vec3.fromValues(tilt, 0, 1)
+ ) as unknown as Vector3,
+ };
+
+ expect(computeReferenceLine(host, peer, metadata)).toBeNull();
+ });
+
+ it('returns null when the intersection line misses the image box', () => {
+ // sagittal plane well outside the i extent
+ const host = slicePlane('Axial', 5, metadata);
+ const peer = {
+ origin: [1000, 0, 0] as Vector3,
+ normal: [1, 0, 0] as Vector3,
+ };
+
+ expect(computeReferenceLine(host, peer, metadata)).toBeNull();
+ });
+
+ it('returns null when the host slice itself is outside the image box', () => {
+ const host = slicePlane('Axial', 500, metadata);
+ const peer = slicePlane('Sagittal', 3, metadata);
+
+ expect(computeReferenceLine(host, peer, metadata)).toBeNull();
+ });
+
+ it('clips an oblique peer plane against the box', () => {
+ // 45 degree plane through the volume: x + z = 10, i.e. normal (1,0,1)/sqrt2
+ const host = slicePlane('Coronal', 6, metadata);
+ const peer = {
+ origin: [10, 0, 0] as Vector3,
+ normal: vec3.normalize(
+ vec3.create(),
+ vec3.fromValues(1, 0, 1)
+ ) as unknown as Vector3,
+ };
+
+ const line = computeReferenceLine(host, peer, metadata);
+
+ // Line: y = 6, x + z = 10. Clipped by x in [-0.5, 9.5] and z in [-0.5, 29.5].
+ // x = -0.5 -> z = 10.5 (in range); x = 9.5 -> z = 0.5 (in range).
+ expectSegment(line, [-0.5, 6, 10.5], [9.5, 6, 0.5]);
+ });
+
+ it('clips in index space for a non-identity direction matrix', () => {
+ // 90 degree rotation about z with anisotropic spacing
+ const direction = mat3.fromValues(0, 1, 0, -1, 0, 0, 0, 0, 1);
+ const rotated = makeMetadata({
+ dimensions: [10, 20, 30],
+ spacing: [2, 1, 1],
+ direction,
+ });
+
+ const host = slicePlane('Axial', 5, rotated);
+ const peer = slicePlane('Sagittal', 3, rotated);
+
+ // Sagittal (ijk 0) runs along world +y with spacing 2 -> y = 6.
+ // The free axis is ijk 1, which runs along world -x with spacing 1,
+ // clipped to index [-0.5, 19.5] -> world x in [-19.5, 0.5].
+ const line = computeReferenceLine(host, peer, rotated);
+
+ expectSegment(line, [0.5, 6, 5], [-19.5, 6, 5]);
+ });
+});
diff --git a/src/referenceLines/__tests__/store.spec.ts b/src/referenceLines/__tests__/store.spec.ts
new file mode 100644
index 000000000..eac1cf2d8
--- /dev/null
+++ b/src/referenceLines/__tests__/store.spec.ts
@@ -0,0 +1,80 @@
+import { describe, it, beforeEach, expect, vi } from 'vitest';
+import { setActivePinia, createPinia } from 'pinia';
+import { nextTick } from 'vue';
+import { useReferenceLinesStore } from '../store';
+import { useToolStore } from '@/src/store/tools';
+import { Tools } from '@/src/store/tools/types';
+
+vi.mock('@/src/core/cine/isCineImage', () => ({
+ isCineImage: () => false,
+ getCineImage: () => null,
+}));
+
+describe('Reference lines store', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ setActivePinia(createPinia());
+ });
+
+ it('defaults to disabled and invisible', () => {
+ const store = useReferenceLinesStore();
+
+ expect(store.enabled).toBe(false);
+ expect(store.visible).toBe(false);
+ });
+
+ it('is visible when the setting is on and no tool is active', () => {
+ const store = useReferenceLinesStore();
+
+ store.enabled = true;
+
+ expect(store.visible).toBe(true);
+ });
+
+ it('shows the lines while crosshairs is active and restores off afterwards', () => {
+ const store = useReferenceLinesStore();
+ const toolStore = useToolStore();
+
+ expect(store.visible).toBe(false);
+
+ toolStore.setCurrentTool(Tools.Crosshairs);
+ expect(store.visible).toBe(true);
+
+ toolStore.setCurrentTool(Tools.Pan);
+ expect(store.visible).toBe(false);
+ });
+
+ it('keeps the lines on across a crosshairs session when the setting is on', () => {
+ const store = useReferenceLinesStore();
+ const toolStore = useToolStore();
+ store.enabled = true;
+
+ toolStore.setCurrentTool(Tools.Crosshairs);
+ expect(store.visible).toBe(true);
+
+ toolStore.setCurrentTool(Tools.Pan);
+ expect(store.visible).toBe(true);
+ });
+
+ it('follows temporary crosshairs activation and deactivation', () => {
+ const store = useReferenceLinesStore();
+ const toolStore = useToolStore();
+ toolStore.setCurrentTool(Tools.Pan);
+
+ toolStore.activateTemporaryCrosshairs();
+ expect(store.visible).toBe(true);
+
+ toolStore.deactivateTemporaryCrosshairs();
+ expect(store.visible).toBe(false);
+ });
+
+ it('persists the setting to localStorage', async () => {
+ const store = useReferenceLinesStore();
+
+ store.enabled = true;
+ await nextTick();
+
+ setActivePinia(createPinia());
+ expect(useReferenceLinesStore().enabled).toBe(true);
+ });
+});
diff --git a/src/referenceLines/__tests__/useReferenceLines.spec.ts b/src/referenceLines/__tests__/useReferenceLines.spec.ts
new file mode 100644
index 000000000..bee260189
--- /dev/null
+++ b/src/referenceLines/__tests__/useReferenceLines.spec.ts
@@ -0,0 +1,147 @@
+import { describe, it, beforeEach, expect, vi } from 'vitest';
+import { ref } from 'vue';
+import { setActivePinia, createPinia } from 'pinia';
+import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
+import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray';
+import { useImageCacheStore } from '@/src/store/image-cache';
+import { useViewStore } from '@/src/store/views';
+import useViewSliceStore from '@/src/store/view-configs/slicing';
+import type { ViewInfo2D } from '@/src/types/views';
+import type { LPSAxis } from '@/src/types/lps';
+import { useReferenceLines } from '../useReferenceLines';
+
+vi.mock('@/src/core/cine/isCineImage', () => ({
+ isCineImage: () => false,
+ getCineImage: () => null,
+}));
+
+const DIMS: [number, number, number] = [10, 20, 30];
+
+const seatImage = (id: string) => {
+ const image = vtkImageData.newInstance();
+ image.setDimensions(...DIMS);
+ image.getPointData().setScalars(
+ vtkDataArray.newInstance({
+ name: 'scalars',
+ numberOfComponents: 1,
+ values: new Uint8Array(DIMS[0] * DIMS[1] * DIMS[2]),
+ })
+ );
+ return useImageCacheStore().addVTKImageData(image, 'test', { id });
+};
+
+const viewIdFor = (axis: LPSAxis) => {
+ const view = useViewStore().layoutViews.find(
+ (candidate): candidate is ViewInfo2D =>
+ candidate.type === '2D' && candidate.options.orientation === axis
+ );
+ if (!view) throw new Error(`no ${axis} view in the default layout`);
+ return view.id;
+};
+
+describe('useReferenceLines', () => {
+ let imageID: string;
+
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ imageID = seatImage('image-1');
+ useViewStore().setDataForAllViews(imageID);
+ });
+
+ it('draws one line per crossing peer view', () => {
+ const sliceStore = useViewSliceStore();
+ const axial = viewIdFor('Axial');
+ const sagittal = viewIdFor('Sagittal');
+ const coronal = viewIdFor('Coronal');
+
+ sliceStore.updateConfig(axial, imageID, { slice: 5, min: 0, max: 29 });
+ sliceStore.updateConfig(sagittal, imageID, { slice: 3, min: 0, max: 9 });
+ sliceStore.updateConfig(coronal, imageID, { slice: 8, min: 0, max: 19 });
+
+ const lines = useReferenceLines(ref(axial), ref(imageID));
+
+ expect(lines.value.map((entry) => entry.viewId).sort()).toEqual(
+ [coronal, sagittal].sort()
+ );
+
+ const sagittalLine = lines.value.find(
+ (entry) => entry.viewId === sagittal
+ )!;
+ // x = 3, z = 5, spanning the inflated j extent
+ expect(sagittalLine.line.p1).toAlmostEqual([3, -0.5, 5]);
+ expect(sagittalLine.line.p2).toAlmostEqual([3, 19.5, 5]);
+ });
+
+ it('follows a peer slice change', () => {
+ const sliceStore = useViewSliceStore();
+ const axial = viewIdFor('Axial');
+ const sagittal = viewIdFor('Sagittal');
+
+ sliceStore.updateConfig(axial, imageID, { slice: 5, min: 0, max: 29 });
+ sliceStore.updateConfig(sagittal, imageID, { slice: 3, min: 0, max: 9 });
+
+ const lines = useReferenceLines(ref(axial), ref(imageID));
+ const first = lines.value.find((entry) => entry.viewId === sagittal)!;
+ expect(first.line.p1[0]).toAlmostEqual(3);
+
+ sliceStore.updateConfig(sagittal, imageID, { slice: 7 });
+
+ const second = lines.value.find((entry) => entry.viewId === sagittal)!;
+ expect(second.line.p1[0]).toAlmostEqual(7);
+ });
+
+ it('draws a line for each of two views on the same axis', () => {
+ const viewStore = useViewStore();
+ const sliceStore = useViewSliceStore();
+ const sagittal = viewIdFor('Sagittal');
+ const coronal = viewIdFor('Coronal');
+
+ // Make the coronal slot a second axial view at a different slice.
+ viewStore.replaceView(coronal, {
+ type: '2D',
+ dataID: imageID,
+ name: 'Axial',
+ options: { orientation: 'Axial' },
+ });
+
+ const axialViews = viewStore.layoutViews.filter(
+ (view): view is ViewInfo2D =>
+ view.type === '2D' && view.options.orientation === 'Axial'
+ );
+ expect(axialViews).toHaveLength(2);
+ axialViews.forEach((view, index) => {
+ sliceStore.updateConfig(view.id, imageID, {
+ slice: 4 + index * 10,
+ min: 0,
+ max: 29,
+ });
+ });
+ sliceStore.updateConfig(sagittal, imageID, { slice: 2, min: 0, max: 9 });
+
+ const sagittalLines = useReferenceLines(ref(sagittal), ref(imageID));
+ expect(sagittalLines.value).toHaveLength(2);
+ expect(
+ sagittalLines.value.map((entry) => entry.line.p1[2]).sort((a, b) => a - b)
+ ).toAlmostEqual([4, 14]);
+
+ // The two axial views draw nothing for each other: parallel planes.
+ const axialLines = useReferenceLines(ref(axialViews[0].id), ref(imageID));
+ expect(axialLines.value.map((entry) => entry.viewId)).toEqual([sagittal]);
+ });
+
+ it('returns nothing when the host view is not a slicing view', () => {
+ const volumeView = useViewStore().layoutViews.find(
+ (view) => view.type === '3D'
+ )!;
+
+ const lines = useReferenceLines(ref(volumeView.id), ref(imageID));
+
+ expect(lines.value).toEqual([]);
+ });
+
+ it('returns nothing without an image', () => {
+ const lines = useReferenceLines(ref(viewIdFor('Axial')), ref(null));
+
+ expect(lines.value).toEqual([]);
+ });
+});
diff --git a/src/referenceLines/crossings.ts b/src/referenceLines/crossings.ts
new file mode 100644
index 000000000..0789a2a64
--- /dev/null
+++ b/src/referenceLines/crossings.ts
@@ -0,0 +1,116 @@
+/**
+ * A projected line, in SVG coordinates.
+ */
+export type ScreenSegment = {
+ id: string;
+ x1: number;
+ y1: number;
+ x2: number;
+ y2: number;
+};
+
+/**
+ * One drawable piece of a segment, uniquely keyed by its source segment.
+ */
+export type SegmentPiece = {
+ key: string;
+ x1: number;
+ y1: number;
+ x2: number;
+ y2: number;
+};
+
+// Two screen segments this close to parallel never register a crossing.
+const CROSSING_EPSILON = 1e-9;
+
+type Interval = [number, number];
+
+/**
+ * The position along `segment`, as a fraction of its length, where `other`
+ * crosses it — or null when they do not cross within both segments.
+ */
+function crossingParameter(
+ segment: ScreenSegment,
+ other: ScreenSegment
+): number | null {
+ const dx = segment.x2 - segment.x1;
+ const dy = segment.y2 - segment.y1;
+ const otherDx = other.x2 - other.x1;
+ const otherDy = other.y2 - other.y1;
+
+ const denominator = dx * otherDy - dy * otherDx;
+ if (Math.abs(denominator) < CROSSING_EPSILON) return null;
+
+ const offsetX = other.x1 - segment.x1;
+ const offsetY = other.y1 - segment.y1;
+ const t = (offsetX * otherDy - offsetY * otherDx) / denominator;
+ const u = (offsetX * dy - offsetY * dx) / denominator;
+
+ if (t < 0 || t > 1 || u < 0 || u > 1) return null;
+ return t;
+}
+
+/** Merges overlapping or touching intervals, in ascending order. */
+function mergeIntervals(intervals: Interval[]): Interval[] {
+ intervals.sort((a, b) => a[0] - b[0]);
+ const merged: Interval[] = [];
+ intervals.forEach((interval) => {
+ const last = merged[merged.length - 1];
+ if (last && interval[0] <= last[1]) {
+ last[1] = Math.max(last[1], interval[1]);
+ } else {
+ merged.push([interval[0], interval[1]]);
+ }
+ });
+ return merged;
+}
+
+/**
+ * Splits projected lines so each one breaks around every point where another
+ * line crosses it, leaving `gap` pixels of clear space centred on the crossing.
+ *
+ * With more than two lines in a view a segment simply gets one gap per
+ * crossing; gaps that would overlap merge into one.
+ */
+export function splitSegmentsAtCrossings(
+ segments: ScreenSegment[],
+ gap: number
+): SegmentPiece[] {
+ return segments.flatMap((segment, index) => {
+ const dx = segment.x2 - segment.x1;
+ const dy = segment.y2 - segment.y1;
+ const length = Math.hypot(dx, dy);
+ if (length === 0) return [];
+
+ const halfGap = gap / 2 / length;
+ const gaps = mergeIntervals(
+ segments.flatMap((other, otherIndex) => {
+ if (otherIndex === index) return [];
+ const t = crossingParameter(segment, other);
+ return t === null
+ ? []
+ : ([[t - halfGap, t + halfGap]] satisfies Interval[]);
+ })
+ );
+
+ const pieces: SegmentPiece[] = [];
+ const emit = (from: number, to: number) => {
+ if (to - from <= 0) return;
+ pieces.push({
+ key: `${segment.id}:${pieces.length}`,
+ x1: segment.x1 + dx * from,
+ y1: segment.y1 + dy * from,
+ x2: segment.x1 + dx * to,
+ y2: segment.y1 + dy * to,
+ });
+ };
+
+ const end = gaps.reduce((cursor, [gapStart, gapEnd]) => {
+ emit(cursor, Math.min(gapStart, 1));
+ return Math.max(cursor, gapEnd);
+ }, 0);
+ emit(end, 1);
+
+ return pieces;
+ });
+}
diff --git a/src/referenceLines/geometry.ts b/src/referenceLines/geometry.ts
new file mode 100644
index 000000000..48e45bbf6
--- /dev/null
+++ b/src/referenceLines/geometry.ts
@@ -0,0 +1,156 @@
+import { mat3, vec3 } from 'gl-matrix';
+import type { Vector3 } from '@kitware/vtk.js/types';
+import vtkPlane from '@kitware/vtk.js/Common/DataModel/Plane';
+import type { ImageMetadata } from '@/src/types/image';
+import type { LPSAxis } from '@/src/types/lps';
+import { IMAGE_BOX_INFLATION } from '@/src/constants';
+
+/**
+ * An infinite plane in world space.
+ */
+export type SlicePlane = {
+ origin: Vector3;
+ /** unit length */
+ normal: Vector3;
+};
+
+/**
+ * A world-space line segment.
+ */
+export type ReferenceLine = {
+ p1: Vector3;
+ p2: Vector3;
+};
+
+// A direction component smaller than this is treated as zero when clipping.
+const DIRECTION_EPSILON = 1e-12;
+
+/**
+ * The world-space plane of a slice along an image axis.
+ *
+ * The normal is taken from `worldToIndex` rather than from a column of the
+ * direction matrix so it stays exactly perpendicular to the slice even when
+ * the direction matrix is not orthonormal.
+ */
+export function slicePlane(
+ axis: LPSAxis,
+ slice: number,
+ metadata: ImageMetadata
+): SlicePlane {
+ const { lpsOrientation, worldToIndex, indexToWorld } = metadata;
+ const ijk = lpsOrientation[axis];
+
+ // Row `ijk` of the linear part of worldToIndex is the gradient of the index
+ // coordinate, i.e. the plane normal (mat4 is column-major).
+ const normal = vec3.fromValues(
+ worldToIndex[ijk],
+ worldToIndex[4 + ijk],
+ worldToIndex[8 + ijk]
+ );
+ vec3.normalize(normal, normal);
+
+ const indexPoint = vec3.create();
+ indexPoint[ijk] = slice;
+ const origin = vec3.transformMat4(vec3.create(), indexPoint, indexToWorld);
+
+ return {
+ normal: Array.from(normal) as Vector3,
+ origin: Array.from(origin) as Vector3,
+ };
+}
+
+/**
+ * Clips the infinite line `p + t*d` against the axis-aligned box
+ * `[-0.5, dim - 0.5]` (Liang–Barsky), returning the parameter interval or null
+ * when the line misses the box.
+ */
+function clipToBox(
+ point: vec3,
+ direction: vec3,
+ dimensions: Vector3
+): [number, number] | null {
+ let tMin = -Infinity;
+ let tMax = Infinity;
+
+ // No axis would constrain the interval, so it would come back unbounded.
+ if (vec3.squaredLength(direction) === 0) return null;
+
+ for (let axis = 0; axis < 3; axis++) {
+ const lo = -IMAGE_BOX_INFLATION;
+ const hi = dimensions[axis] - 1 + IMAGE_BOX_INFLATION;
+ const d = direction[axis];
+ const p = point[axis];
+
+ if (Math.abs(d) < DIRECTION_EPSILON) {
+ if (p < lo || p > hi) return null;
+ } else {
+ const t1 = (lo - p) / d;
+ const t2 = (hi - p) / d;
+ tMin = Math.max(tMin, Math.min(t1, t2));
+ tMax = Math.min(tMax, Math.max(t1, t2));
+ if (tMin > tMax) return null;
+ }
+ }
+
+ return [tMin, tMax];
+}
+
+/**
+ * The world-space segment where the `peer` plane cuts the `host` plane, clipped
+ * to the image box.
+ *
+ * Returns null when the planes are (near-)parallel — which is also how
+ * same-axis peers are rejected — or when the intersection misses the image.
+ */
+export function computeReferenceLine(
+ host: SlicePlane,
+ peer: SlicePlane,
+ metadata: ImageMetadata
+): ReferenceLine | null {
+ // vtk.js types `intersectWithPlane` with the `intersectWithLine` result
+ // shape; the plane-plane overload actually returns the two line points.
+ const { intersection, l0, l1 } = vtkPlane.intersectWithPlane(
+ host.origin,
+ host.normal,
+ peer.origin,
+ peer.normal
+ ) as unknown as { intersection: boolean; l0: Vector3; l1: Vector3 };
+ if (!intersection) return null;
+
+ const { worldToIndex, indexToWorld, dimensions } = metadata;
+
+ // Clip in index space, where the image box is axis-aligned regardless of the
+ // image's direction matrix.
+ const indexPoint = vec3.transformMat4(
+ vec3.create(),
+ l0 as vec3,
+ worldToIndex
+ );
+ // A direction is a vector, so it takes only the linear part of the transform.
+ // `l1 - l0` is the cross product of the unit normals, whose magnitude is
+ // sin(angle); it is normalized first because for nearly parallel planes that
+ // is small enough to vanish against a world coordinate in float32.
+ const indexDirection = vec3.transformMat3(
+ vec3.create(),
+ vec3.normalize(
+ vec3.create(),
+ vec3.subtract(vec3.create(), l1 as vec3, l0 as vec3)
+ ),
+ mat3.fromMat4(mat3.create(), worldToIndex)
+ );
+
+ const interval = clipToBox(indexPoint, indexDirection, dimensions as Vector3);
+ if (!interval) return null;
+
+ const [tMin, tMax] = interval;
+ const toWorld = (t: number) =>
+ Array.from(
+ vec3.transformMat4(
+ vec3.create(),
+ vec3.scaleAndAdd(vec3.create(), indexPoint, indexDirection, t),
+ indexToWorld
+ )
+ ) as Vector3;
+
+ return { p1: toWorld(tMin), p2: toWorld(tMax) };
+}
diff --git a/src/referenceLines/index.ts b/src/referenceLines/index.ts
new file mode 100644
index 000000000..a338c534e
--- /dev/null
+++ b/src/referenceLines/index.ts
@@ -0,0 +1,5 @@
+// Sole entry point for the reference lines feature, enforced by eslint import
+// zones.
+
+export { default as ReferenceLines } from './ReferenceLines.vue';
+export { useReferenceLinesStore } from './store';
diff --git a/src/referenceLines/store.ts b/src/referenceLines/store.ts
new file mode 100644
index 000000000..d95728373
--- /dev/null
+++ b/src/referenceLines/store.ts
@@ -0,0 +1,21 @@
+import { defineStore } from 'pinia';
+import { computed } from 'vue';
+import { useLocalStorage } from '@vueuse/core';
+import { useToolStore } from '@/src/store/tools';
+import { Tools } from '@/src/store/tools/types';
+
+const STORAGE_KEY = 'referenceLinesEnabled';
+
+export const useReferenceLinesStore = defineStore('referenceLines', () => {
+ const enabled = useLocalStorage(STORAGE_KEY, false);
+ const toolStore = useToolStore();
+
+ // The crosshairs tool has no visuals of its own: the reference lines are what
+ // it draws, so it turns them on for as long as it is active. Deriving this
+ // rather than snapshotting is what restores the user's setting afterwards.
+ const visible = computed(
+ () => enabled.value || toolStore.currentTool === Tools.Crosshairs
+ );
+
+ return { enabled, visible };
+});
diff --git a/src/referenceLines/useReferenceLines.ts b/src/referenceLines/useReferenceLines.ts
new file mode 100644
index 000000000..cd0bca931
--- /dev/null
+++ b/src/referenceLines/useReferenceLines.ts
@@ -0,0 +1,59 @@
+import { computed, unref, type MaybeRef } from 'vue';
+import type { Maybe } from '@/src/types';
+import {
+ computeEffectiveView,
+ volume2DViewsOfImage,
+} from '@/src/core/views/effectiveView';
+import { useImage } from '@/src/composables/useCurrentImage';
+import { useViewStore } from '@/src/store/views';
+import useViewSliceStore from '@/src/store/view-configs/slicing';
+import { computeReferenceLine, slicePlane } from './geometry';
+
+/**
+ * World-space reference line segments to draw in the host view, one per peer
+ * slicing view that actually crosses it.
+ *
+ * Read-only by construction: it never writes a slice config.
+ */
+export function useReferenceLines(
+ viewId: MaybeRef,
+ imageId: MaybeRef>
+) {
+ const viewStore = useViewStore();
+ const sliceStore = useViewSliceStore();
+ const { metadata } = useImage(imageId);
+
+ return computed(() => {
+ const hostViewId = unref(viewId);
+ const imageID = unref(imageId);
+ if (!imageID) return [];
+
+ const hostView = viewStore.getView(hostViewId);
+ if (!hostView) return [];
+
+ const hostEffective = computeEffectiveView(hostView, imageID);
+ if (hostEffective.kind !== 'volume2D') return [];
+
+ const imageMetadata = metadata.value;
+ const hostPlane = slicePlane(
+ hostEffective.axis,
+ sliceStore.getConfig(hostViewId, imageID).slice,
+ imageMetadata
+ );
+
+ // Same-axis peers are deliberately kept: they are rejected by the parallel
+ // test in `computeReferenceLine`, which is also what will reject
+ // near-parallel oblique peers once those become line sources.
+ return volume2DViewsOfImage(imageID, viewStore.layoutViews)
+ .filter((peer) => peer.viewId !== hostViewId)
+ .flatMap((peer) => {
+ const peerPlane = slicePlane(
+ peer.axis,
+ sliceStore.getConfig(peer.viewId, imageID).slice,
+ imageMetadata
+ );
+ const line = computeReferenceLine(hostPlane, peerPlane, imageMetadata);
+ return line ? [{ viewId: peer.viewId, line }] : [];
+ });
+ });
+}
diff --git a/src/store/__tests__/views.spec.ts b/src/store/__tests__/views.spec.ts
index 00fc49df0..e167b41d4 100644
--- a/src/store/__tests__/views.spec.ts
+++ b/src/store/__tests__/views.spec.ts
@@ -90,3 +90,47 @@ describe('View store', () => {
expect(store.viewByID['view-1'].dataID).toBe('loaded-image');
});
});
+
+describe('View store layoutViews', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ });
+
+ it('lists every layout slot view, in slot order', () => {
+ const store = useViewStore();
+
+ expect(store.layoutViews.map((view) => view.name)).toEqual([
+ 'Axial',
+ 'Coronal',
+ 'Sagittal',
+ 'Volume',
+ ]);
+ });
+
+ it('drops views the layout no longer has a slot for', () => {
+ const store = useViewStore();
+ const allIds = store.layoutViews.map((view) => view.id);
+
+ store.setLayoutFromGrid([1, 1]);
+
+ expect(store.layoutViews.map((view) => view.id)).toEqual([allIds[0]]);
+ // The orphaned views survive so a later layout switch can reuse them.
+ expect(store.viewIDs).toEqual(expect.arrayContaining(allIds));
+
+ store.setLayoutFromGrid([2, 2]);
+
+ expect(store.layoutViews.map((view) => view.id)).toEqual(allIds);
+ });
+
+ it('keeps hidden layout peers while a view is maximized', () => {
+ const store = useViewStore();
+ const allIds = store.layoutViews.map((view) => view.id);
+ expect(allIds.length).toBeGreaterThan(1);
+
+ store.setActiveView(allIds[0]);
+ store.toggleActiveViewMaximized();
+
+ expect(store.visibleViews).toHaveLength(1);
+ expect(store.layoutViews.map((view) => view.id)).toEqual(allIds);
+ });
+});
diff --git a/src/store/tools/__tests__/crosshairs.spec.ts b/src/store/tools/__tests__/crosshairs.spec.ts
new file mode 100644
index 000000000..66c76fc2c
--- /dev/null
+++ b/src/store/tools/__tests__/crosshairs.spec.ts
@@ -0,0 +1,148 @@
+import { describe, it, beforeEach, expect, vi } from 'vitest';
+import { nextTick } from 'vue';
+import { setActivePinia, createPinia } from 'pinia';
+import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
+import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray';
+import { useImageCacheStore } from '@/src/store/image-cache';
+import { useViewStore } from '@/src/store/views';
+import useViewSliceStore from '@/src/store/view-configs/slicing';
+import type { ViewInfo2D } from '@/src/types/views';
+import type { LPSAxis } from '@/src/types/lps';
+import { useCrosshairsToolStore } from '@/src/store/tools/crosshairs';
+
+vi.mock('@/src/core/cine/isCineImage', () => ({
+ isCineImage: () => false,
+ getCineImage: () => null,
+}));
+
+const SMALL: [number, number, number] = [10, 20, 30];
+const LARGE: [number, number, number] = [40, 50, 60];
+
+const seatImage = (id: string, dimensions: [number, number, number]) => {
+ const image = vtkImageData.newInstance();
+ image.setDimensions(...dimensions);
+ image.getPointData().setScalars(
+ vtkDataArray.newInstance({
+ name: 'scalars',
+ numberOfComponents: 1,
+ values: new Uint8Array(dimensions[0] * dimensions[1] * dimensions[2]),
+ })
+ );
+ return useImageCacheStore().addVTKImageData(image, 'test', { id });
+};
+
+const viewIdFor = (axis: LPSAxis) => {
+ const view = useViewStore().layoutViews.find(
+ (candidate): candidate is ViewInfo2D =>
+ candidate.type === '2D' && candidate.options.orientation === axis
+ );
+ if (!view) throw new Error(`no ${axis} view in the default layout`);
+ return view.id;
+};
+
+describe('crosshairs tool store', () => {
+ let imageA: string;
+ let imageB: string;
+ let axialA: string;
+ let sagittalA: string;
+ let coronalB: string;
+
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ imageA = seatImage('image-a', SMALL);
+ imageB = seatImage('image-b', LARGE);
+
+ const viewStore = useViewStore();
+ axialA = viewIdFor('Axial');
+ sagittalA = viewIdFor('Sagittal');
+ coronalB = viewIdFor('Coronal');
+
+ viewStore.setDataForView(axialA, imageA);
+ viewStore.setDataForView(sagittalA, imageA);
+ viewStore.setDataForView(coronalB, imageB);
+
+ const sliceStore = useViewSliceStore();
+ sliceStore.updateConfig(axialA, imageA, { slice: 5, min: 0, max: 29 });
+ sliceStore.updateConfig(sagittalA, imageA, { slice: 3, min: 0, max: 9 });
+ sliceStore.updateConfig(coronalB, imageB, { slice: 25, min: 0, max: 49 });
+ });
+
+ const sliceOf = (viewId: string, imageId: string) =>
+ useViewSliceStore().getConfig(viewId, imageId)?.slice;
+
+ it('slices every view showing the image the crosshair was moved in', () => {
+ useCrosshairsToolStore().setPosition([2, 8, 17], axialA);
+
+ expect(sliceOf(sagittalA, imageA)).toBe(2);
+ expect(sliceOf(axialA, imageA)).toBe(17);
+ });
+
+ it('leaves views showing another image untouched', () => {
+ useCrosshairsToolStore().setPosition([2, 8, 17], axialA);
+
+ expect(sliceOf(coronalB, imageB)).toBe(25);
+ });
+
+ it('does not move any slices when the active view changes', async () => {
+ const viewStore = useViewStore();
+ viewStore.setActiveView(axialA);
+ useCrosshairsToolStore().setPosition([2, 8, 17], axialA);
+
+ // Clicking or scroll-scrubbing another image's view focuses it. That is
+ // not a crosshair interaction and must not re-slice anything.
+ viewStore.setActiveView(coronalB);
+ await nextTick();
+
+ expect(sliceOf(coronalB, imageB)).toBe(25);
+ expect(sliceOf(axialA, imageA)).toBe(17);
+ expect(sliceOf(sagittalA, imageA)).toBe(2);
+ });
+
+ it('slices the peers of the moved view even when another view is active', () => {
+ const viewStore = useViewStore();
+ viewStore.setActiveView(coronalB);
+
+ useCrosshairsToolStore().setPosition([2, 8, 17], axialA);
+
+ expect(sliceOf(sagittalA, imageA)).toBe(2);
+ expect(sliceOf(coronalB, imageB)).toBe(25);
+ });
+
+ it('ignores views the layout has dropped', () => {
+ const viewStore = useViewStore();
+ const orphaned = sagittalA;
+
+ // Replacing the slot's view leaves the old one in the store, unslotted.
+ viewStore.replaceView(orphaned, {
+ type: '2D',
+ dataID: imageA,
+ name: 'Sagittal',
+ options: { orientation: 'Sagittal' },
+ });
+ expect(viewStore.layoutViews.map((view) => view.id)).not.toContain(
+ orphaned
+ );
+
+ useCrosshairsToolStore().setPosition([2, 8, 17], axialA);
+
+ expect(sliceOf(orphaned, imageA)).toBe(3);
+ });
+
+ it('clamps the crosshair to the image the moved view shows', () => {
+ // Well outside image A, but inside image B.
+ useCrosshairsToolStore().setPosition([35, 45, 55], axialA);
+
+ expect(sliceOf(sagittalA, imageA)).toBe(9);
+ expect(sliceOf(axialA, imageA)).toBe(29);
+ });
+
+ it('does nothing for a view without an image', () => {
+ const viewStore = useViewStore();
+ const empty = viewStore.layoutViews.find((view) => view.type === '3D')!;
+
+ useCrosshairsToolStore().setPosition([2, 8, 17], empty.id);
+
+ expect(sliceOf(axialA, imageA)).toBe(5);
+ expect(sliceOf(coronalB, imageB)).toBe(25);
+ });
+});
diff --git a/src/store/tools/crosshairs.ts b/src/store/tools/crosshairs.ts
index 01b31d549..c90c481ca 100644
--- a/src/store/tools/crosshairs.ts
+++ b/src/store/tools/crosshairs.ts
@@ -1,106 +1,78 @@
-import { useCurrentImage } from '@/src/composables/useCurrentImage';
import vtkCrosshairsWidget from '@/src/vtk/CrosshairsWidget';
-import type { Bounds, Vector3 } from '@kitware/vtk.js/types';
-import vtkBoundingBox from '@kitware/vtk.js/Common/DataModel/BoundingBox';
-import { computed, ref, unref, watch } from 'vue';
+import type { Vector3 } from '@kitware/vtk.js/types';
import { vec3 } from 'gl-matrix';
import { defineStore } from 'pinia';
-import { Manifest, StateFile } from '@/src/io/state-file/schema';
import { useViewStore } from '@/src/store/views';
import useViewSliceStore from '@/src/store/view-configs/slicing';
-import { ViewInfo2D } from '@/src/types/views';
-import { computeEffectiveView } from '@/src/core/views/effectiveView';
+import { getImageMetadata } from '@/src/composables/useCurrentImage';
+import {
+ computeEffectiveView,
+ volume2DViewsOfImage,
+} from '@/src/core/views/effectiveView';
+import { IMAGE_BOX_INFLATION } from '@/src/constants';
+import { clampValue } from '@/src/utils';
+import type { ImageMetadata } from '@/src/types/image';
+
+/** The world point in `metadata`'s index space, pulled inside the image box. */
+function clampToImage(worldPosition: Vector3, metadata: ImageMetadata) {
+ const index = vec3.transformMat4(
+ vec3.create(),
+ worldPosition as vec3,
+ metadata.worldToIndex
+ );
+ metadata.dimensions.forEach((dim, axis) => {
+ index[axis] = clampValue(
+ index[axis],
+ -IMAGE_BOX_INFLATION,
+ dim - 1 + IMAGE_BOX_INFLATION
+ );
+ });
+ return index;
+}
export const useCrosshairsToolStore = defineStore('crosshairs', () => {
type _This = ReturnType;
const factory = vtkCrosshairsWidget.newInstance();
const widgetState = factory.getWidgetState();
- const handle = widgetState.getHandle();
-
- const active = ref(false);
- const { currentImageID, currentImageMetadata } = useCurrentImage('global');
-
- // world-space
- const position = ref([0, 0, 0]);
- // image space
- const imagePosition = computed(() => {
- const out = vec3.create();
- vec3.transformMat4(
- out,
- position.value,
- currentImageMetadata.value.worldToIndex
- );
- return out as Vector3;
- });
const viewSliceStore = useViewSliceStore();
const viewStore = useViewStore();
- const otherViews = computed(() => {
- return viewStore
- .getViewsForData(unref(currentImageID))
- .filter((view): view is ViewInfo2D => view.type === '2D');
- });
-
function getWidgetFactory(this: _This) {
return factory;
}
- function setPosition(pos: Vector3) {
- position.value = pos;
- }
-
- // update the slicing
- watch(imagePosition, (indexPos) => {
- if (!active.value) {
- return;
- }
- const imageID = unref(currentImageID);
- if (!imageID) {
- return;
- }
- const { lpsOrientation } = unref(currentImageMetadata);
-
- otherViews.value.forEach((view) => {
- const effective = computeEffectiveView(view, imageID);
- if (effective.kind !== 'volume2D') return;
- const index = lpsOrientation[effective.axis];
- const slice = Math.round(indexPos[index]);
- viewSliceStore.updateConfig(view.id, imageID, { slice });
- });
- });
-
- // update widget state based on current image
- watch(
- currentImageMetadata,
- (metadata) => {
- widgetState.setIndexToWorld(metadata.indexToWorld);
- widgetState.setWorldToIndex(metadata.worldToIndex);
- const [xDim, yDim, zDim] = metadata.dimensions;
- const imageBounds: Bounds = [0, xDim - 1, 0, yDim - 1, 0, zDim - 1];
- // inflate by 0.5, since the image slice rendering is inflated
- // by 0.5.
- handle.setBounds(vtkBoundingBox.inflate(imageBounds, 0.5));
- },
- { immediate: true }
- );
-
- // update the position
- handle.onModified(() => {
- const origin = handle.getOrigin();
- if (origin) {
- position.value = origin;
- }
- });
-
- function activateTool() {
- active.value = true;
- return true;
+ /**
+ * Moves the crosshair to a world point picked in `viewId` and slices the
+ * views showing that same image to match.
+ *
+ * The image comes from the view the point was picked in rather than from
+ * whichever view happens to be active, so images sharing a layout keep their
+ * own slicing.
+ *
+ * All registered views of the image are sliced, not just those in the
+ * current layout, so views hidden by a layout switch come back consistent.
+ */
+ function setPosition(worldPosition: Vector3, viewId: string) {
+ const view = viewStore.getView(viewId);
+ const host = view && computeEffectiveView(view, view.dataID);
+ if (host?.kind !== 'volume2D') return;
+
+ const imageID = host.renderDataID;
+ const metadata = getImageMetadata(imageID);
+ const indexPosition = clampToImage(worldPosition, metadata);
+
+ const { lpsOrientation } = metadata;
+ volume2DViewsOfImage(imageID, viewStore.getAllViews()).forEach(
+ ({ viewId: peerId, axis }) => {
+ const slice = Math.round(indexPosition[lpsOrientation[axis]]);
+ viewSliceStore.updateConfig(peerId, imageID, { slice });
+ }
+ );
}
function deactivateTool() {
- active.value = false;
widgetState.setDragging(false);
}
@@ -108,28 +80,10 @@ export const useCrosshairsToolStore = defineStore('crosshairs', () => {
widgetState.setDragging(dragging);
}
- function serialize(state: StateFile) {
- const crosshairs = state.manifest.tools?.crosshairs;
- if (!crosshairs) return;
- crosshairs.position = position.value;
- }
-
- function deserialize(manifest: Manifest) {
- const crosshairsPosition = manifest.tools?.crosshairs?.position;
- if (crosshairsPosition) {
- position.value = crosshairsPosition;
- }
- }
-
return {
getWidgetFactory,
setPosition,
- position,
- imagePosition,
- activateTool,
deactivateTool,
setDragging,
- serialize,
- deserialize,
};
});
diff --git a/src/store/views.ts b/src/store/views.ts
index ec6b1ea73..b1039afea 100644
--- a/src/store/views.ts
+++ b/src/store/views.ts
@@ -148,16 +148,23 @@ export const useViewStore = defineStore('view', () => {
);
});
- const visibleViews = computed(() => {
- if (maximizedView.value) return [maximizedView.value];
+ // Every view the current layout assigns a slot to, whether or not it is on
+ // screen. Reading the layout rather than `viewByID` keeps views orphaned by a
+ // layout switch out of the list.
+ const layoutViews = computed(() => {
const views: ViewInfo[] = [];
iterLayout(layout.value, (item) => {
- const viewId = layoutSlots.value[item.slotIndex];
- views.push(viewByID[viewId]);
+ const view = viewByID[layoutSlots.value[item.slotIndex]];
+ if (view) views.push(view);
});
return views;
});
+ const visibleViews = computed(() => {
+ if (maximizedView.value) return [maximizedView.value];
+ return layoutViews.value;
+ });
+
const viewIDs = computed(() => Object.keys(viewByID));
function getView(id: Maybe) {
@@ -422,6 +429,7 @@ export const useViewStore = defineStore('view', () => {
return layout.value;
}),
visibleViews,
+ layoutViews,
viewIDs,
activeView,
viewByID,
diff --git a/src/vtk/CrosshairsWidget/behavior.ts b/src/vtk/CrosshairsWidget/behavior.ts
index c648a23e1..3192f5ef7 100644
--- a/src/vtk/CrosshairsWidget/behavior.ts
+++ b/src/vtk/CrosshairsWidget/behavior.ts
@@ -1,12 +1,4 @@
import macro from '@kitware/vtk.js/macro';
-import type { Bounds } from '@kitware/vtk.js/types';
-import { vec3 } from 'gl-matrix';
-
-function clampPointToBounds(bounds: Bounds, point: vec3) {
- return point.map((p, i) =>
- Math.max(bounds[i * 2], Math.min(bounds[i * 2 + 1], p))
- ) as vec3;
-}
export default function widgetBehavior(publicAPI: any, model: any) {
model.classHierarchy.push('vtkCrosshairsWidgetProp');
@@ -50,29 +42,16 @@ export default function widgetBehavior(publicAPI: any, model: any) {
model.pickable &&
model.manipulator
) {
- const { worldCoords: worldCoordsOfPointer } =
- model.manipulator.handleEvent(callData, model._apiSpecificRenderWindow);
-
- const handle = model.widgetState.getHandle();
- const worldToIndex = model.widgetState.getWorldToIndex();
- const indexToWorld = model.widgetState.getIndexToWorld();
- if (worldToIndex.length && indexToWorld.length) {
- const indexCoordsOfPointer = vec3.create();
- const worldOrigin = vec3.create();
- const bounds = handle.getBounds();
-
- vec3.transformMat4(
- indexCoordsOfPointer,
- worldCoordsOfPointer,
- worldToIndex
- );
- const indexOrigin = clampPointToBounds(bounds, indexCoordsOfPointer);
- vec3.transformMat4(worldOrigin, indexOrigin, indexToWorld);
-
- handle.setOrigin(worldOrigin);
- publicAPI.invokeInteractionEvent();
- return macro.EVENT_ABORT;
- }
+ const { worldCoords } = model.manipulator.handleEvent(
+ callData,
+ model._apiSpecificRenderWindow
+ );
+
+ // The point is left unclamped here: only the view this widget belongs to
+ // knows which image it should be confined to.
+ model.widgetState.getHandle().setOrigin(worldCoords);
+ publicAPI.invokeInteractionEvent();
+ return macro.EVENT_ABORT;
}
return macro.VOID;
diff --git a/src/vtk/CrosshairsWidget/index.d.ts b/src/vtk/CrosshairsWidget/index.d.ts
index 1954378ea..5a7b61524 100644
--- a/src/vtk/CrosshairsWidget/index.d.ts
+++ b/src/vtk/CrosshairsWidget/index.d.ts
@@ -1,7 +1,6 @@
import vtkAbstractWidget from '@kitware/vtk.js/Widgets/Core/AbstractWidget';
import vtkAbstractWidgetFactory from '@kitware/vtk.js/Widgets/Core/AbstractWidgetFactory';
import vtkPlaneManipulator from '@kitware/vtk.js/Widgets/Manipulators/PlaneManipulator';
-import { mat4, vec3 } from 'gl-matrix';
import { CrosshairsWidgetState } from './state';
export interface vtkCrosshairsViewWidget extends vtkAbstractWidget {
diff --git a/src/vtk/CrosshairsWidget/index.js b/src/vtk/CrosshairsWidget/index.js
index 36bbc7f0f..c235ec40e 100644
--- a/src/vtk/CrosshairsWidget/index.js
+++ b/src/vtk/CrosshairsWidget/index.js
@@ -34,15 +34,6 @@ function vtkCrosshairsWidget(publicAPI, model) {
// initialization
// --------------------------------------------------------------------------
- model.widgetState.onBoundsChange((bounds) => {
- const center = [
- (bounds[0] + bounds[1]) * 0.5,
- (bounds[2] + bounds[3]) * 0.5,
- (bounds[4] + bounds[5]) * 0.5,
- ];
- model.widgetState.getHandle().setOrigin(center);
- });
-
// Default manipulator
model.manipulator = vtkPlanePointManipulator.newInstance();
}
diff --git a/src/vtk/CrosshairsWidget/state.ts b/src/vtk/CrosshairsWidget/state.ts
index 404f23d37..6a745a71d 100644
--- a/src/vtk/CrosshairsWidget/state.ts
+++ b/src/vtk/CrosshairsWidget/state.ts
@@ -1,7 +1,6 @@
-import type { Bounds, Vector3 } from '@kitware/vtk.js/types';
+import type { Vector3 } from '@kitware/vtk.js/types';
import vtkStateBuilder from '@kitware/vtk.js/Widgets/Core/StateBuilder';
import vtkWidgetState from '@kitware/vtk.js/Widgets/Core/WidgetState';
-import { mat4 } from 'gl-matrix';
export interface CrosshairsHandleWidgetState extends vtkWidgetState {
setOrigin(origin: Vector3 | null): boolean;
@@ -10,17 +9,11 @@ export interface CrosshairsHandleWidgetState extends vtkWidgetState {
getScale1(): number;
setVisible(visible: boolean): boolean;
getVisible(): boolean;
- setBounds(bounds: Bounds): boolean;
- getBounds(): Bounds;
}
export interface CrosshairsWidgetState extends vtkWidgetState {
setDragging(dragging: boolean): boolean;
getDragging(): boolean;
- setIndexToWorld(indexToWorld: mat4): boolean;
- getIndexToWorld(): mat4;
- setWorldToIndex(worldToIndex: mat4): boolean;
- getWorldToIndex(): mat4;
getHandle(): CrosshairsHandleWidgetState;
}
@@ -31,17 +24,9 @@ export default function generateState() {
name: 'dragging',
initialValue: false,
})
- .addField({
- name: 'indexToWorld',
- initialValue: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
- })
- .addField({
- name: 'worldToIndex',
- initialValue: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
- })
.addStateFromMixin({
labels: ['handle'],
- mixins: ['origin', 'bounds'],
+ mixins: ['origin'],
name: 'handle',
initialValues: {
origin: null,
diff --git a/tests/specs/crosshairs-multi-image.e2e.ts b/tests/specs/crosshairs-multi-image.e2e.ts
new file mode 100644
index 000000000..76cfa9230
--- /dev/null
+++ b/tests/specs/crosshairs-multi-image.e2e.ts
@@ -0,0 +1,136 @@
+import { MRA_HEAD_NECK_DATASET, PROSTATEX_DATASET } from './configTestUtils';
+import { openUrls } from './utils';
+import { volViewPage } from '../pageobjects/volview.page';
+
+const IMAGE_DRAG_MEDIA_TYPE = 'application/x-volview-image-id';
+
+// Four Up: the loaded image fills every slot, so these two get the other one.
+const FIRST_OTHER_SLOT = 2;
+const SECOND_OTHER_SLOT = 1;
+
+/**
+ * Dispatches a real dragstart on a volume card so the app stamps the imageID
+ * into the dataTransfer, then synthesizes the drop on a layout slot.
+ */
+const dropCardOnSlot = (cardIndex: number, slotIndex: number) =>
+ browser.execute(
+ (card_: number, index: number, mediaType: string) => {
+ const card = document.querySelectorAll('.volume-card')[card_] as
+ | HTMLElement
+ | undefined;
+ const slot = document.querySelectorAll('.grid-item')[index] as
+ | HTMLElement
+ | undefined;
+ if (!card || !slot) return false;
+
+ const data = new DataTransfer();
+ card.dispatchEvent(
+ new DragEvent('dragstart', { bubbles: true, dataTransfer: data })
+ );
+ if (!data.getData(mediaType)) return false;
+
+ slot.dispatchEvent(
+ new DragEvent('dragenter', { bubbles: true, dataTransfer: data })
+ );
+ slot.dispatchEvent(
+ new DragEvent('drop', { bubbles: true, dataTransfer: data })
+ );
+ return true;
+ },
+ cardIndex,
+ slotIndex,
+ IMAGE_DRAG_MEDIA_TYPE
+ );
+
+/** The slice each layout slot reports, or null where a slot shows none. */
+const getSlices = () =>
+ browser.execute(() =>
+ Array.from(document.querySelectorAll('.grid-item')).map((slot) => {
+ const match = slot.textContent?.match(/Slice:\s*(\d+)/);
+ return match ? parseInt(match[1], 10) : null;
+ })
+ );
+
+const slotCenter = async (index: number) => {
+ const canvas = await $$('.grid-item')[index].$('canvas');
+ const { x, y } = await canvas.getLocation();
+ const { width, height } = await canvas.getSize();
+ return {
+ x: Math.round(x + width / 2),
+ y: Math.round(y + height / 2),
+ };
+};
+
+const dragInSlot = async (index: number, dx: number, dy: number) => {
+ const { x, y } = await slotCenter(index);
+ await browser
+ .action('pointer')
+ .move({ x, y })
+ .down()
+ .move({ x: x + dx, y: y + dy })
+ .up()
+ .perform();
+};
+
+describe('Crosshairs with two base images', () => {
+ it('only slices the views showing the image it was dragged in', async () => {
+ await openUrls([PROSTATEX_DATASET, MRA_HEAD_NECK_DATASET]);
+ await volViewPage.waitForViews();
+ await browser.waitUntil(
+ async () => (await $$('.volume-card').length) >= 2,
+ {
+ timeout: 30000,
+ timeoutMsg: 'Expected both volume cards to appear',
+ }
+ );
+ await browser.waitUntil(async () => (await $$('.grid-item').length) === 4, {
+ timeout: 10000,
+ timeoutMsg: 'Expected the Four Up layout (4 slots)',
+ });
+
+ // The loaded image fills every slot; the other one is the card that is not
+ // marked active, and is what these drops mount.
+ const otherCard = await browser.execute(() =>
+ Array.from(document.querySelectorAll('.volume-card')).findIndex(
+ (candidate) => !candidate.classList.contains('volume-card-active')
+ )
+ );
+ expect(otherCard).toBeGreaterThanOrEqual(0);
+
+ expect(await dropCardOnSlot(otherCard, FIRST_OTHER_SLOT)).toBe(true);
+ await browser.waitUntil(
+ async () => (await getSlices())[FIRST_OTHER_SLOT] != null,
+ { timeout: 15000, timeoutMsg: 'Expected the dropped image to render' }
+ );
+
+ const crosshairsButton = await $('button span i[class~=mdi-crosshairs]');
+ await crosshairsButton.waitForClickable();
+ await crosshairsButton.click();
+
+ const before = await getSlices();
+
+ // Drag the crosshair well off centre in a view of the other image.
+ await dragInSlot(0, 60, 40);
+
+ await browser.waitUntil(async () => (await getSlices())[1] !== before[1], {
+ timeoutMsg:
+ 'Expected the crosshair drag to re-slice the peer view of the same image',
+ });
+
+ const afterDrag = await getSlices();
+ expect(afterDrag[FIRST_OTHER_SLOT]).toBe(before[FIRST_OTHER_SLOT]);
+
+ // Mounting that image into a second slot makes it the active image. That is
+ // not a crosshair interaction, so the slot already showing it must not be
+ // re-sliced to wherever the crosshair sits in the other image.
+ expect(await dropCardOnSlot(otherCard, SECOND_OTHER_SLOT)).toBe(true);
+ await browser.waitUntil(
+ async () => (await getSlices())[SECOND_OTHER_SLOT] !== afterDrag[1],
+ { timeout: 15000, timeoutMsg: 'Expected the second slot to remount' }
+ );
+
+ const afterRemount = await getSlices();
+ expect(afterRemount[FIRST_OTHER_SLOT]).toBe(before[FIRST_OTHER_SLOT]);
+ expect(afterRemount[0]).toBe(afterDrag[0]);
+ });
+});
diff --git a/tests/specs/reference-lines.e2e.ts b/tests/specs/reference-lines.e2e.ts
new file mode 100644
index 000000000..63eb3fd35
--- /dev/null
+++ b/tests/specs/reference-lines.e2e.ts
@@ -0,0 +1,202 @@
+import { PROSTATEX_DATASET } from './configTestUtils';
+import { openUrls } from './utils';
+import { volViewPage } from '../pageobjects/volview.page';
+
+const SETTING_LABEL = 'label*=Reference Lines';
+
+/**
+ * The reference-line segments currently drawn, grouped by 2D view in layout
+ * order. Each segment is [x1, y1, x2, y2].
+ */
+const getReferenceLines = () =>
+ browser.execute(() => {
+ const views = Array.from(
+ document.querySelectorAll('div[data-testid~="vtk-two-view"]')
+ );
+ return views.map((view) =>
+ Array.from(
+ view.querySelectorAll('svg[data-testid="reference-lines"] line')
+ ).map((element) => {
+ const line = element as SVGLineElement;
+ return [
+ line.x1.baseVal.value,
+ line.y1.baseVal.value,
+ line.x2.baseVal.value,
+ line.y2.baseVal.value,
+ ];
+ })
+ );
+ });
+
+const countReferenceLines = async () => {
+ const lines = await getReferenceLines();
+ return lines.reduce((sum, viewLines) => sum + viewLines.length, 0);
+};
+
+const waitForReferenceLineCount = async (
+ predicate: (count: number) => boolean,
+ timeoutMsg: string
+) => {
+ await browser.waitUntil(async () => predicate(await countReferenceLines()), {
+ timeoutMsg,
+ });
+};
+
+// Must match CROSSING_GAP in src/referenceLines/ReferenceLines.vue.
+const CROSSING_GAP = 16;
+
+/**
+ * The distance between the facing ends of each collinear pair of pieces in a
+ * view — i.e. the width of the break left where another line crosses.
+ */
+const collinearGaps = (viewLines: number[][]) => {
+ const direction = ([x1, y1, x2, y2]: number[]) => {
+ const length = Math.hypot(x2 - x1, y2 - y1);
+ return [(x2 - x1) / length, (y2 - y1) / length];
+ };
+ const endpoints = ([x1, y1, x2, y2]: number[]) => [
+ [x1, y1],
+ [x2, y2],
+ ];
+
+ const gaps: number[] = [];
+ viewLines.forEach((a, indexA) => {
+ viewLines.slice(indexA + 1).forEach((b) => {
+ const [ax, ay] = direction(a);
+ const [bx, by] = direction(b);
+ if (Math.abs(ax * by - ay * bx) > 1e-3) return;
+ gaps.push(
+ Math.min(
+ ...endpoints(a).flatMap(([px, py]) =>
+ endpoints(b).map(([qx, qy]) => Math.hypot(px - qx, py - qy))
+ )
+ )
+ );
+ });
+ });
+ return gaps;
+};
+
+const clickToolButton = async (iconClass: string) => {
+ const button = await $(`button span i[class~=${iconClass}]`);
+ await button.waitForClickable();
+ await button.click();
+};
+
+const setReferenceLinesSetting = async (on: boolean) => {
+ const settingsButton = await $(
+ 'button[data-testid="control-button-Settings"]'
+ );
+ await settingsButton.waitForClickable();
+ await settingsButton.click();
+
+ const label = await $(SETTING_LABEL);
+ await label.waitForClickable();
+ const isOn = (await label.getText()).includes('On');
+ if (isOn !== on) {
+ await label.click();
+ await browser.waitUntil(
+ async () =>
+ (await $(SETTING_LABEL).getText()).includes(on ? 'On' : 'Off'),
+ { timeoutMsg: `Reference Lines switch did not turn ${on ? 'on' : 'off'}` }
+ );
+ }
+
+ await browser.keys(['Escape']);
+ await browser.waitUntil(async () => !(await $(SETTING_LABEL).isExisting()), {
+ timeoutMsg: 'Settings dialog did not close',
+ });
+};
+
+describe('Reference lines', () => {
+ before(async () => {
+ await openUrls([PROSTATEX_DATASET]);
+ await volViewPage.waitForViews();
+ });
+
+ after(async () => {
+ // The setting is browser-level, so leave the profile as we found it.
+ await setReferenceLinesSetting(false);
+ });
+
+ it('draws no lines by default', async () => {
+ expect(await countReferenceLines()).toBe(0);
+ });
+
+ it('shows lines while Crosshairs is active and hides them afterwards', async () => {
+ await clickToolButton('mdi-crosshairs');
+
+ await waitForReferenceLineCount(
+ (count) => count > 0,
+ 'Expected reference lines once Crosshairs was activated'
+ );
+
+ // Each 2D view draws one line per cross-axis peer, and each of those two
+ // lines is broken in half by the gap where they cross.
+ const lines = await getReferenceLines();
+ expect(lines.length).toBe(3);
+ lines.forEach((viewLines) => {
+ expect(viewLines.length).toBe(4);
+ // The two halves of each line are separated by the crossing gap.
+ const gaps = collinearGaps(viewLines);
+ expect(gaps.length).toBe(2);
+ gaps.forEach((gap) =>
+ expect(Math.abs(gap - CROSSING_GAP)).toBeLessThan(1)
+ );
+ });
+
+ await clickToolButton('mdi-cursor-move');
+
+ await waitForReferenceLineCount(
+ (count) => count === 0,
+ 'Expected reference lines to disappear when Crosshairs was deactivated'
+ );
+ });
+
+ it('keeps lines on independently of any tool when the setting is on', async () => {
+ await setReferenceLinesSetting(true);
+
+ await waitForReferenceLineCount(
+ (count) => count > 0,
+ 'Expected reference lines from the setting alone'
+ );
+
+ // A Crosshairs session must not clear a user-enabled setting.
+ await clickToolButton('mdi-crosshairs');
+ await waitForReferenceLineCount(
+ (count) => count > 0,
+ 'Expected reference lines while Crosshairs was active'
+ );
+ await clickToolButton('mdi-cursor-move');
+ await waitForReferenceLineCount(
+ (count) => count > 0,
+ 'Expected reference lines to survive Crosshairs deactivation'
+ );
+ });
+
+ it('moves the host plane line in the peer views when the slice changes', async () => {
+ const before = await getReferenceLines();
+ expect(before.length).toBe(3);
+
+ await volViewPage.focusFirst2DView();
+ await volViewPage.advanceSliceAndWait();
+
+ await browser.waitUntil(
+ async () => {
+ const after = await getReferenceLines();
+ // The scrolled view's plane moved, so its line moved in both peers.
+ return [1, 2].every((viewIndex) =>
+ after[viewIndex].some(
+ (segment, lineIndex) =>
+ JSON.stringify(segment) !==
+ JSON.stringify(before[viewIndex][lineIndex])
+ )
+ );
+ },
+ {
+ timeoutMsg:
+ 'Expected the scrolled view line to move in the other 2D views',
+ }
+ );
+ });
+});