@niivue/niivue
Version:
minimal webgl2 nifti image viewer
451 lines (392 loc) • 13.6 kB
text/typescript
/**
* Slice rendering helper functions for 2D slice visualization.
* This module provides pure functions for slice rendering operations.
*
* Related to: 2D slice rendering (axial, coronal, sagittal), mosaic views, crosshairs
*/
import { SLICE_TYPE } from '@/nvdocument'
// WebGL texture unit constants
const TEXTURE0_BACK_VOL = 33984
const TEXTURE2_OVERLAY_VOL = 33986
/**
* Parameters for updating texture interpolation
*/
export interface UpdateInterpolationParams {
gl: WebGL2RenderingContext
layer: number
isForceLinear?: boolean
isNearestInterpolation: boolean
is2DSliceShader: boolean
}
/**
* Update texture interpolation mode (nearest or linear) for background or overlay layer.
* @param params - Parameters containing GL context, layer index, and interpolation settings
*/
export function updateInterpolation(params: UpdateInterpolationParams): void {
const { gl, layer, isForceLinear = false, isNearestInterpolation, is2DSliceShader } = params
let interp: number = gl.LINEAR
if (!isForceLinear && isNearestInterpolation) {
interp = gl.NEAREST
}
if (layer === 0) {
gl.activeTexture(TEXTURE0_BACK_VOL) // background
// Use 2D texture for background when is2DSliceShader is true
if (is2DSliceShader) {
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, interp)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, interp)
} else {
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MIN_FILTER, interp)
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MAG_FILTER, interp)
}
} else {
gl.activeTexture(TEXTURE2_OVERLAY_VOL) // overlay
// Overlay is always a 3D texture
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MIN_FILTER, interp)
gl.texParameteri(gl.TEXTURE_3D, gl.TEXTURE_MAG_FILTER, interp)
}
}
/**
* Parsed mosaic item with rendering information
*/
export interface MosaicItem {
axCorSag: SLICE_TYPE
sliceMM: number
isRender: boolean
isLabel: boolean
isCrossLines: boolean
}
/**
* Result of parsing a mosaic string
*/
export interface MosaicParseResult {
items: MosaicItem[][]
axiMM: number[]
corMM: number[]
sagMM: number[]
horizontalOverlap: number
}
/**
* Parse a mosaic string into structured tile information.
* @param mosaicStr - The mosaic string specification (e.g., "A -10 0 20; C 0")
* @returns Parsed mosaic structure with rows of items and slice positions
*/
export function parseMosaicString(mosaicStr: string): MosaicParseResult {
const normalizedStr = mosaicStr.replaceAll(';', ' ;').trim()
const tokens = normalizedStr.split(/\s+/)
const axiMM: number[] = []
const corMM: number[] = []
const sagMM: number[] = []
const rows: MosaicItem[][] = []
let currentRow: MosaicItem[] = []
let horizontalOverlap = 0
let axCorSag = SLICE_TYPE.AXIAL
let isRender = false
let isLabel = false
let isCrossLines = false
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i]
if (token.includes('X')) {
isCrossLines = true
continue
}
if (token.includes('L')) {
isLabel = !token.includes('-')
continue
}
if (token.includes('H')) {
i++
horizontalOverlap = Math.abs(Math.max(0, Math.min(1, parseFloat(tokens[i]))))
continue
}
if (token.includes('V')) {
i++
continue
}
if (token.includes('A')) {
axCorSag = SLICE_TYPE.AXIAL
continue
}
if (token.includes('C')) {
axCorSag = SLICE_TYPE.CORONAL
continue
}
if (token.includes('S')) {
axCorSag = SLICE_TYPE.SAGITTAL
continue
}
if (token.includes('R')) {
isRender = true
continue
}
if (token.includes(';')) {
if (currentRow.length > 0) {
rows.push(currentRow)
currentRow = []
}
continue
}
const sliceMM = parseFloat(token)
if (isNaN(sliceMM)) {
continue
}
// Track slice positions for crosshairs
if (!isRender) {
if (axCorSag === SLICE_TYPE.AXIAL) {
axiMM.push(sliceMM)
}
if (axCorSag === SLICE_TYPE.CORONAL) {
corMM.push(sliceMM)
}
if (axCorSag === SLICE_TYPE.SAGITTAL) {
sagMM.push(sliceMM)
}
}
currentRow.push({
axCorSag,
sliceMM,
isRender,
isLabel,
isCrossLines
})
// Reset per-item flags after creating item
isRender = false
isCrossLines = false
}
// Don't forget the last row
if (currentRow.length > 0) {
rows.push(currentRow)
}
return {
items: rows,
axiMM,
corMM,
sagMM,
horizontalOverlap
}
}
/**
* Parameters for calculating mosaic tile layout
*/
export interface MosaicLayoutParams {
regionWidth: number
regionHeight: number
tileMargin: number
centerMosaic: boolean
getFovMM: (axCorSag: SLICE_TYPE, isRender: boolean) => [number, number, number]
tileGap?: number
}
/**
* Layout information for a single mosaic tile
*/
export interface MosaicTileLayout {
left: number
top: number
width: number
height: number
item: MosaicItem
}
/**
* Result of mosaic layout calculation
*/
export interface MosaicLayoutResult {
tiles: MosaicTileLayout[]
scale: number
marginLeft: number
marginTop: number
}
/**
* Calculate layout positions for mosaic tiles.
* @param params - Layout parameters
* @param parsedMosaic - Parsed mosaic structure
* @returns Layout positions for all tiles
*/
export function calculateMosaicLayout(params: MosaicLayoutParams, parsedMosaic: MosaicParseResult): MosaicLayoutResult {
const { regionWidth, regionHeight, tileMargin, centerMosaic, getFovMM, tileGap = 0 } = params
const { items, horizontalOverlap } = parsedMosaic
// First pass: calculate total dimensions
let totalHeight = 0
let maxRowWidth = 0
const rowLayouts: Array<{ width: number; height: number; tiles: Array<{ w: number; h: number; item: MosaicItem }> }> = []
for (const row of items) {
let rowWidth = 0
let rowHeight = 0
const rowTiles: Array<{ w: number; h: number; item: MosaicItem }> = []
let prevWidth = 0
for (const item of row) {
const fov = getFovMM(item.axCorSag, item.isRender)
const w = item.axCorSag === SLICE_TYPE.SAGITTAL ? fov[1] : fov[0]
const h = item.axCorSag === SLICE_TYPE.AXIAL ? fov[1] : fov[2]
// Apply horizontal overlap
if (horizontalOverlap > 0 && !item.isRender && rowTiles.length > 0) {
rowWidth += Math.round(prevWidth * (1.0 - horizontalOverlap))
} else if (rowTiles.length > 0) {
rowWidth += prevWidth + tileGap
}
rowTiles.push({ w, h, item })
rowHeight = Math.max(rowHeight, h)
prevWidth = w
}
// Add the last tile width
rowWidth += prevWidth
rowLayouts.push({ width: rowWidth, height: rowHeight, tiles: rowTiles })
totalHeight += rowHeight
maxRowWidth = Math.max(maxRowWidth, rowWidth)
}
if (maxRowWidth <= 0 || totalHeight <= 0) {
return { tiles: [], scale: 1, marginLeft: 0, marginTop: 0 }
}
// Calculate scale to fit in region
const scaleW = (regionWidth - 2 * tileMargin - tileGap) / maxRowWidth
const scaleH = (regionHeight - 2 * tileMargin) / totalHeight
const scale = Math.min(scaleW, scaleH)
// Calculate margins
let marginLeft = tileMargin
let marginTop = tileMargin
if (centerMosaic) {
marginLeft = Math.floor(0.5 * (regionWidth - maxRowWidth * scale))
marginTop = Math.floor(0.5 * (regionHeight - totalHeight * scale))
}
// Second pass: calculate actual positions
const tiles: MosaicTileLayout[] = []
let top = 0
for (const rowLayout of rowLayouts) {
let left = 0
let prevWidth = 0
for (let i = 0; i < rowLayout.tiles.length; i++) {
const tile = rowLayout.tiles[i]
// Apply horizontal overlap
if (horizontalOverlap > 0 && !tile.item.isRender && i > 0) {
left += Math.round(prevWidth * (1.0 - horizontalOverlap))
} else if (i > 0) {
left += prevWidth + tileGap
}
tiles.push({
left,
top,
width: tile.w,
height: tile.h,
item: tile.item
})
prevWidth = tile.w
}
top += rowLayout.height
}
return { tiles, scale, marginLeft, marginTop }
}
/**
* Get the lines arrays (horizontal and vertical) for a given slice type.
* @param axCorSag - Slice orientation
* @param axiMM - Axial slice positions
* @param corMM - Coronal slice positions
* @param sagMM - Sagittal slice positions
* @returns Object with linesH and linesV arrays
*/
export function getCrossLinesForSliceType(axCorSag: SLICE_TYPE, axiMM: number[], corMM: number[], sagMM: number[]): { linesH: number[]; linesV: number[] } {
let linesH = corMM.slice()
let linesV = sagMM.slice()
if (axCorSag === SLICE_TYPE.CORONAL) {
linesH = axiMM.slice()
}
if (axCorSag === SLICE_TYPE.SAGITTAL) {
linesH = axiMM.slice()
linesV = corMM.slice()
}
return { linesH, linesV }
}
/**
* Get the slice dimension index for a given slice type.
* @param axCorSag - Slice orientation
* @returns Dimension index (0=i/sagittal, 1=j/coronal, 2=k/axial)
*/
export function getSliceDimension(axCorSag: SLICE_TYPE): number {
if (axCorSag === SLICE_TYPE.CORONAL) {
return 1 // j dimension
}
if (axCorSag === SLICE_TYPE.SAGITTAL) {
return 0 // i dimension
}
return 2 // k dimension (axial)
}
/**
* Calculate azimuth and elevation angles for a given slice type and orientation.
* @param axCorSag - Slice orientation
* @param isRadiological - Whether to use radiological convention
* @returns Object with azimuth and elevation in degrees
*/
export function getSliceAngles(axCorSag: SLICE_TYPE, isRadiological: boolean): { azimuth: number; elevation: number } {
let elevation = 0
let azimuth = 0
if (axCorSag === SLICE_TYPE.SAGITTAL) {
azimuth = isRadiological ? 90 : -90
} else if (axCorSag === SLICE_TYPE.CORONAL) {
azimuth = isRadiological ? 180 : 0
} else {
// AXIAL
azimuth = isRadiological ? 180 : 0
elevation = isRadiological ? -90 : 90
}
return { azimuth, elevation }
}
/**
* Determine if radiological convention should be used based on options and slice type.
* @param axCorSag - Slice orientation
* @param isRadiologicalConvention - Global radiological convention setting
* @param sagittalNoseLeft - Sagittal nose left setting
* @param customMM - Custom MM value (Infinity or -Infinity for special cases)
* @returns Whether to use radiological convention for rendering
*/
export function determineRadiologicalConvention(axCorSag: SLICE_TYPE, isRadiologicalConvention: boolean, sagittalNoseLeft: boolean, customMM: number): boolean {
let isRadiological = isRadiologicalConvention && axCorSag < SLICE_TYPE.SAGITTAL
if (customMM === Infinity || customMM === -Infinity) {
isRadiological = customMM !== Infinity
if (axCorSag === SLICE_TYPE.CORONAL) {
isRadiological = !isRadiological
}
} else if (sagittalNoseLeft && axCorSag === SLICE_TYPE.SAGITTAL) {
isRadiological = !isRadiological
}
return isRadiological
}
/**
* Calculate width and height to fit a slice within a container, preserving aspect ratio.
* @param sliceType - Slice orientation
* @param volScale - Volume scale factors [x, y, z]
* @param containerWidth - Container width in pixels
* @param containerHeight - Container height in pixels
* @returns Tuple of [actualWidth, actualHeight]
*/
export function calculateSliceDimensions(sliceType: SLICE_TYPE, volScale: number[], containerWidth: number, containerHeight: number): [number, number] {
let xScale: number
let yScale: number
switch (sliceType) {
case SLICE_TYPE.AXIAL:
xScale = volScale[0]
yScale = volScale[1]
break
case SLICE_TYPE.CORONAL:
xScale = volScale[0]
yScale = volScale[2]
break
case SLICE_TYPE.SAGITTAL:
xScale = volScale[1]
yScale = volScale[2]
break
default:
return [containerWidth, containerHeight]
}
// Calculate scale factor to fit within container while preserving aspect ratio
const aspectRatio = xScale / yScale
const containerAspect = containerWidth / containerHeight
let actualWidth: number
let actualHeight: number
if (aspectRatio > containerAspect) {
// width-constrained
actualWidth = containerWidth
actualHeight = containerWidth / aspectRatio
} else {
// height-constrained
actualHeight = containerHeight
actualWidth = containerHeight * aspectRatio
}
return [actualWidth, actualHeight]
}