From cd56bef4edc74a10b677ff3c6cbba54876a8fa7b Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Sat, 15 Aug 2026 17:03:57 -0400 Subject: [PATCH] feat(views): draw reference lines independently of the crosshairs tool Cross-reference lines were drawn inside the crosshairs tool, so they only existed while that tool was active. Pull them out into a standalone feature that a setting can turn on persistently, and that the crosshairs tool borrows as its on-screen appearance while it is active. Visibility is derived (enabled || crosshairs active) rather than snapshot and restored, which gives the auto-on/restore behavior with no extra state and covers press-and-hold temporary crosshairs for free. The geometry is a general plane-plane intersection clipped to the image box in index space, not an axis-aligned special case, so oblique planes only need wiring into the peer enumeration later. The intersection direction is transformed by the linear part of worldToIndex: a direction is a vector, so it never wanted the translation, and recovering it by subtracting two transformed points collapsed to zero in float32 for nearly parallel planes. Peers come from the layout slots, so lines survive maximizing a view and no stale viewByID entry can produce a ghost line. Each line breaks around every point where another drawn line crosses it, leaving 16px of clear space. Doing that as a screen-space pass over the projected segments keeps the gap decoupled from the crosshairs position, so it appears the same way in always-on mode. The crosshairs tool keeps its slicing inside the image its view shows. The slice write used to hang off a watcher on the crosshair position in the active image's index space, so merely focusing another image's view slammed every view of the newly active image to wherever the crosshair had been left in the other one. The move now propagates from the interaction itself: the view widget reports which view the point was picked in, and the store resolves the image from that view, clamps the point in its index space and slices only the layout views showing it. That leaves the widget state with nothing image-specific to track, so the shared matrices and handle bounds go away with it. The feature lives in src/referenceLines, following src/processing: a single public surface plus a pure layer of geometry, crossings and peer enumeration, with the boundary enforced by eslint import zones. Behavior changes that come with the pull-out: - lines sit at the peers' rounded slice positions and step during a crosshairs drag instead of tracking the pointer continuously - lines no longer hide when a view's slice differs from the crosshair slice - no lines when there is no peer 2D view --- eslint.config.js | 190 +++++++------- src/components/Settings.vue | 14 ++ src/components/SliceViewer.vue | 15 +- .../tools/crosshairs/CrosshairSVG2D.vue | 114 --------- .../tools/crosshairs/CrosshairsTool.vue | 81 ------ .../tools/crosshairs/CrosshairsWidget2D.vue | 11 + src/constants.ts | 3 + .../views/__tests__/effectiveView.spec.ts | 65 ++++- src/core/views/effectiveView.ts | 13 + src/io/state-file/schema.ts | 7 - src/io/state-file/serialize.ts | 3 - src/referenceLines/ReferenceLines.vue | 58 +++++ .../__tests__/crossings.spec.ts | 146 +++++++++++ src/referenceLines/__tests__/geometry.spec.ts | 233 ++++++++++++++++++ src/referenceLines/__tests__/store.spec.ts | 80 ++++++ .../__tests__/useReferenceLines.spec.ts | 147 +++++++++++ src/referenceLines/crossings.ts | 116 +++++++++ src/referenceLines/geometry.ts | 156 ++++++++++++ src/referenceLines/index.ts | 5 + src/referenceLines/store.ts | 21 ++ src/referenceLines/useReferenceLines.ts | 59 +++++ src/store/__tests__/views.spec.ts | 44 ++++ src/store/tools/__tests__/crosshairs.spec.ts | 148 +++++++++++ src/store/tools/crosshairs.ts | 152 ++++-------- src/store/views.ts | 16 +- src/vtk/CrosshairsWidget/behavior.ts | 41 +-- src/vtk/CrosshairsWidget/index.d.ts | 1 - src/vtk/CrosshairsWidget/index.js | 9 - src/vtk/CrosshairsWidget/state.ts | 19 +- tests/specs/crosshairs-multi-image.e2e.ts | 136 ++++++++++ tests/specs/reference-lines.e2e.ts | 202 +++++++++++++++ 31 files changed, 1853 insertions(+), 452 deletions(-) delete mode 100644 src/components/tools/crosshairs/CrosshairSVG2D.vue delete mode 100644 src/components/tools/crosshairs/CrosshairsTool.vue create mode 100644 src/referenceLines/ReferenceLines.vue create mode 100644 src/referenceLines/__tests__/crossings.spec.ts create mode 100644 src/referenceLines/__tests__/geometry.spec.ts create mode 100644 src/referenceLines/__tests__/store.spec.ts create mode 100644 src/referenceLines/__tests__/useReferenceLines.spec.ts create mode 100644 src/referenceLines/crossings.ts create mode 100644 src/referenceLines/geometry.ts create mode 100644 src/referenceLines/index.ts create mode 100644 src/referenceLines/store.ts create mode 100644 src/referenceLines/useReferenceLines.ts create mode 100644 src/store/tools/__tests__/crosshairs.spec.ts create mode 100644 tests/specs/crosshairs-multi-image.e2e.ts create mode 100644 tests/specs/reference-lines.e2e.ts 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', + } + ); + }); +});