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', + } + ); + }); +});