/** * Slice navigation helper functions for slice plane navigation and positioning. * This module provides pure functions for slice navigation logic. * * Related to: Slice scrolling, tile indexing, slice position tracking */ import { vec3, vec4 } from 'gl-matrix' import { SLICE_TYPE, DRAG_MODE } from '../../nvdocument.js' /** * Screen slice information for tile calculations */ export interface ScreenSlice { leftTopWidthHeight: number[] axCorSag: SLICE_TYPE [key: string]: unknown } /** * Graph display state (matches the Graph interface from nvdocument) */ export interface GraphState { opacity: number plotLTWH?: number[] | null LTWH: number[] } /** * Parameters for finding tile index */ export interface TileIndexParams { x: number y: number screenSlices: ScreenSlice[] } /** * Parameters for checking render tile */ export interface InRenderTileParams { x: number y: number screenSlices: ScreenSlice[] } /** * Parameters for checking graph tile */ export interface InGraphTileParams { x: number y: number graph: GraphState volumesLength: number firstVolumeFrameCount: number } /** * Parameters for checking if coordinates are in bounds */ export interface InBoundsParams { x: number y: number dpr: number canvasHeight: number boundsRegion: [number, number, number, number] } /** * Parameters for getting slice position from crosshair */ export interface GetSlicePositionParams { sliceType: SLICE_TYPE crosshairPos: vec3 } /** * Parameters for getting current slice info */ export interface GetCurrentSliceInfoParams { dragStart: number[] screenSlices: ScreenSlice[] crosshairPos: vec3 currentSliceType: SLICE_TYPE canvasPos2frac: (pos: number[]) => vec3 } /** * Slice info result */ export interface SliceInfo { sliceIndex: number sliceType: SLICE_TYPE slicePosition: number } /** * Parameters for checking if measurement should be drawn on current slice */ export interface ShouldDrawOnSliceParams { sliceIndex: number sliceType: SLICE_TYPE slicePosition: number currentSliceTypeOpt: SLICE_TYPE screenSlices: ScreenSlice[] crosshairPos: vec3 } /** * Parameters for calculating 3D scroll */ export interface SliceScroll3DParams { posChange: number volumesLength: number clipPlaneDepthAziElevs: number[][] activeClipPlaneIndex: number volScaleMultiplier: number } /** * Result of 3D scroll calculation */ export interface SliceScroll3DResult { action: 'none' | 'clipPlane' | 'zoom' newClipPlaneDepth?: number newVolScaleMultiplier?: number } /** * Parameters for calculating 2D zoom scroll */ export interface ZoomScrollParams { posChange: number currentZoom: number yoke3Dto2DZoom: boolean crosshairMM: vec4 | number[] } /** * Result of zoom scroll calculation */ export interface ZoomScrollResult { newZoom: number zoomChange: number panOffsetX: number panOffsetY: number panOffsetZ: number newVolScaleMultiplier?: number } /** * Parameters for calculating voxel movement */ export interface MoveCrosshairVoxParams { x: number y: number z: number currentVox: vec3 dimsRAS: number[] } /** * Result of voxel movement calculation */ export interface MoveCrosshairVoxResult { newVox: vec3 zChanged: boolean } /** * Returns the index of the tile containing the given (x, y) screen coordinates. * Returns -1 if the coordinates are outside all tiles. */ export function findTileIndex(params: TileIndexParams): number { const { x, y, screenSlices } = params for (let i = 0; i < screenSlices.length; i++) { const ltwh = screenSlices[i].leftTopWidthHeight if (x > ltwh[0] && y > ltwh[1] && x < ltwh[0] + ltwh[2] && y < ltwh[1] + ltwh[3]) { return i } } return -1 // mouse position not in any tile } /** * Returns the index of the render tile containing (x, y) screen coordinates, or -1 if none. */ export function findRenderTileIndex(params: InRenderTileParams): number { const { x, y, screenSlices } = params const idx = findTileIndex({ x, y, screenSlices }) if (idx >= 0 && screenSlices[idx].axCorSag === SLICE_TYPE.RENDER) { return idx } return -1 // mouse position not in rendering tile } /** * Checks if (x,y) is within the visible graph plotting area. */ export function isInGraphTile(params: InGraphTileParams): boolean { const { x, y, graph, volumesLength, firstVolumeFrameCount } = params if (graph.opacity <= 0 || volumesLength < 1 || firstVolumeFrameCount < 1 || !graph.plotLTWH) { return false } if (graph.plotLTWH[2] < 1 || graph.plotLTWH[3] < 1) { return false } const pos = [(x - graph.LTWH[0]) / graph.LTWH[2], (y - graph.LTWH[1]) / graph.LTWH[3]] return pos[0] > 0 && pos[1] > 0 && pos[0] <= 1 && pos[1] <= 1 } /** * Return true if the given canvas pixel coordinates are inside the bounds region. */ export function isInBounds(params: InBoundsParams): boolean { const { x, y, dpr, canvasHeight, boundsRegion } = params const [vpX, vpY, vpW, vpH] = boundsRegion // Convert from CSS (top origin, unscaled) → GL pixels (bottom origin) const glX = x * dpr const glY = canvasHeight - y * dpr return glX >= vpX && glX <= vpX + vpW && glY >= vpY && glY <= vpY + vpH } /** * Get the current slice position based on slice type. */ export function getSlicePosition(params: GetSlicePositionParams): number { const { sliceType, crosshairPos } = params if (sliceType === SLICE_TYPE.AXIAL) { return crosshairPos[2] // Z coordinate for axial slices } else if (sliceType === SLICE_TYPE.CORONAL) { return crosshairPos[1] // Y coordinate for coronal slices } else if (sliceType === SLICE_TYPE.SAGITTAL) { return crosshairPos[0] // X coordinate for sagittal slices } return 0 } /** * Get slice information for the current measurement/angle. */ export function getCurrentSliceInfo(params: GetCurrentSliceInfoParams): SliceInfo { const { dragStart, screenSlices, crosshairPos, currentSliceType, canvasPos2frac } = params const tileIdx = findTileIndex({ x: dragStart[0], y: dragStart[1], screenSlices }) if (tileIdx >= 0 && tileIdx < screenSlices.length) { const sliceType = screenSlices[tileIdx].axCorSag const slicePosition = getSlicePosition({ sliceType, crosshairPos }) return { sliceIndex: tileIdx, sliceType, slicePosition } } // Fallback: use current slice type and crosshair position when tileIndex fails let slicePosition = 0 if (currentSliceType === SLICE_TYPE.AXIAL) { slicePosition = crosshairPos[2] } else if (currentSliceType === SLICE_TYPE.CORONAL) { slicePosition = crosshairPos[1] } else if (currentSliceType === SLICE_TYPE.SAGITTAL) { slicePosition = crosshairPos[0] } else if (currentSliceType === SLICE_TYPE.MULTIPLANAR) { // In multiplanar mode, try to determine from canvas position const startFrac = canvasPos2frac([dragStart[0], dragStart[1]]) if (startFrac[0] >= 0) { // Default to axial in multiplanar mode slicePosition = crosshairPos[2] } } return { sliceIndex: -1, sliceType: currentSliceType, slicePosition } } /** * Check if a measurement/angle should be drawn on the current slice. */ export function shouldDrawOnCurrentSlice(params: ShouldDrawOnSliceParams): boolean { const { sliceType, slicePosition, currentSliceTypeOpt, screenSlices, crosshairPos } = params // In multiplanar mode, check if measurement can be displayed on any visible tile if (currentSliceTypeOpt === SLICE_TYPE.MULTIPLANAR) { // Check if this is a valid 2D slice type if (sliceType > SLICE_TYPE.SAGITTAL) { return false } for (let i = 0; i < screenSlices.length; i++) { if (screenSlices[i].axCorSag === sliceType) { // Check if the position matches (within tolerance) const currentSlicePosition = getSlicePosition({ sliceType, crosshairPos }) const tolerance = 0.001 const difference = Math.abs(currentSlicePosition - slicePosition) if (difference < tolerance) { return true } } } return false } // For single slice types, check if the type matches if (sliceType !== currentSliceTypeOpt) { return false } // Check if the position matches (within tolerance) const currentSlicePosition = getSlicePosition({ sliceType, crosshairPos }) const tolerance = 0.001 const difference = Math.abs(currentSlicePosition - slicePosition) return difference < tolerance } /** * Calculate 3D scroll behavior - either clip plane adjustment or zoom. */ export function calculateSliceScroll3D(params: SliceScroll3DParams): SliceScroll3DResult { const { posChange, volumesLength, clipPlaneDepthAziElevs, activeClipPlaneIndex, volScaleMultiplier } = params if (posChange === 0) { return { action: 'none' } } // Clip plane only influences voxel-based volumes if (volumesLength > 0 && clipPlaneDepthAziElevs[activeClipPlaneIndex][0] < 1.8) { // Clipping mode: change clip plane depth const currentDepth = clipPlaneDepthAziElevs[activeClipPlaneIndex][0] let newDepth = currentDepth // Bound clip sqrt(3) = 1.73 if (posChange > 0) { newDepth = Math.min(1.5, currentDepth + 0.025) } if (posChange < 0) { newDepth = Math.max(-1.5, currentDepth - 0.025) } if (newDepth !== currentDepth) { return { action: 'clipPlane', newClipPlaneDepth: newDepth } } return { action: 'none' } } // Zoom mode for meshes or when clip plane is disabled let newScale = volScaleMultiplier if (posChange > 0) { newScale = Math.min(2.0, volScaleMultiplier * 1.1) } if (posChange < 0) { newScale = Math.max(0.5, volScaleMultiplier * 0.9) } return { action: 'zoom', newVolScaleMultiplier: newScale } } /** * Calculate zoom scroll values for 2D pan/zoom mode. */ export function calculateZoomScroll(params: ZoomScrollParams): ZoomScrollResult { const { posChange, currentZoom, yoke3Dto2DZoom, crosshairMM } = params let zoom = currentZoom * (1.0 + 10 * posChange) zoom = Math.round(zoom * 10) / 10 const zoomChange = currentZoom - zoom return { newZoom: zoom, zoomChange, panOffsetX: zoomChange * crosshairMM[0], panOffsetY: zoomChange * crosshairMM[1], panOffsetZ: zoomChange * crosshairMM[2], newVolScaleMultiplier: yoke3Dto2DZoom ? zoom : undefined } } /** * Calculate new voxel position after movement, clamped to volume bounds. */ export function calculateMoveCrosshairInVox(params: MoveCrosshairVoxParams): MoveCrosshairVoxResult { const { x, y, z, currentVox, dimsRAS } = params const newVox = vec3.clone(currentVox) const originalZ = newVox[2] newVox[0] += x newVox[1] += y newVox[2] += z // Clamp to volume bounds newVox[0] = Math.max(0, Math.min(newVox[0], dimsRAS[1] - 1)) newVox[1] = Math.max(0, Math.min(newVox[1], dimsRAS[2] - 1)) newVox[2] = Math.max(0, Math.min(newVox[2], dimsRAS[3] - 1)) return { newVox, zChanged: originalZ !== newVox[2] } } /** * Get normalized graph position from pixel coordinates. * Returns null if position is invalid. */ export function getGraphPosition(params: { x: number; y: number; plotLTWH: number[] }): { pos: [number, number]; isInPlot: boolean; isInDownloadArea: boolean } | null { const { x, y, plotLTWH } = params const pos: [number, number] = [(x - plotLTWH[0]) / plotLTWH[2], (y - plotLTWH[1]) / plotLTWH[3]] const isInPlot = pos[0] > 0 && pos[1] > 0 && pos[0] <= 1 && pos[1] <= 1 const isInDownloadArea = pos[0] > 0.5 && pos[1] > 1.0 return { pos, isInPlot, isInDownloadArea } } /** * Calculate frame from graph click position. */ export function calculateFrameFromGraphClick(params: { normalizedX: number; totalFrames: number }): number { const { normalizedX, totalFrames } = params return Math.round(normalizedX * (totalFrames - 1)) } /** * Determine the axis index for crosshair movement based on slice type. * Returns [xDelta, yDelta, zDelta] for a position change in that slice. */ export function getAxisDeltaForSlice(params: { sliceType: SLICE_TYPE; posNeg: number }): [number, number, number] { const { sliceType, posNeg } = params const xyz: [number, number, number] = [0, 0, 0] if (sliceType <= SLICE_TYPE.SAGITTAL) { xyz[2 - sliceType] = posNeg } return xyz } /** * Check if a slice type is a 2D slice (axial, coronal, or sagittal). */ export function is2DSliceType(sliceType: SLICE_TYPE): boolean { return sliceType <= SLICE_TYPE.SAGITTAL } /** * Check if scrolling should be processed based on focus requirements. */ export function shouldProcessScroll(params: { scrollRequiresFocus: boolean; canvasHasFocus: boolean }): boolean { const { scrollRequiresFocus, canvasHasFocus } = params return !scrollRequiresFocus || canvasHasFocus } /** * Check if zoom scroll should be applied (pan mode with position outside render tile). */ export function shouldApplyZoomScroll(params: { posChange: number; dragMode: DRAG_MODE; isInRenderTile: boolean }): boolean { const { posChange, dragMode, isInRenderTile } = params return posChange !== 0 && dragMode === DRAG_MODE.pan && !isInRenderTile }