/** * Pen drawing tool pure functions for freehand drawing operations. * * This module provides pure functions for pen-based drawing including * single point drawing, line drawing, and filled polygon drawing. * * Related modules: * - DrawingManager.ts - Drawing state management and undo/redo * - ShapeTool.ts - Rectangle and ellipse drawing * - FloodFillTool.ts - Flood fill and click-to-segment (e.g. magic wand) */ import { log } from '@/logger' import { decodeRLE } from '@/drawing' // ============================================================================ // Types and Interfaces // ============================================================================ /** * Slice orientation types for pen drawing */ export const enum PEN_SLICE_TYPE { AXIAL = 0, CORONAL = 1, SAGITTAL = 2 } /** * Parameters for drawing a single point */ export interface DrawPointParams { /** X coordinate in voxel space */ x: number /** Y coordinate in voxel space */ y: number /** Z coordinate in voxel space */ z: number /** Pen value (color index) to draw */ penValue: number /** Drawing bitmap to modify */ drawBitmap: Uint8Array /** Volume dimensions [unused, dimX, dimY, dimZ, ...] */ dims: number[] /** Pen size in voxels */ penSize: number /** Current slice orientation (-1, 0=axial, 1=coronal, 2=sagittal) */ penAxCorSag: number } /** * Parameters for drawing a line between two points */ export interface DrawLineParams { /** Start point [x, y, z] in voxel space */ ptA: number[] /** End point [x, y, z] in voxel space */ ptB: number[] /** Pen value (color index) to draw */ penValue: number /** Drawing bitmap to modify */ drawBitmap: Uint8Array /** Volume dimensions [unused, dimX, dimY, dimZ, ...] */ dims: number[] /** Pen size in voxels */ penSize: number /** Current slice orientation (-1, 0=axial, 1=coronal, 2=sagittal) */ penAxCorSag: number } /** * Parameters for flood fill section operation */ export interface FloodFillSectionParams { /** 2D image bitmap to fill */ img2D: Uint8Array /** 2D dimensions [width, height] */ dims2D: readonly number[] /** Minimum point of bounding box [x, y] */ minPt: readonly number[] /** Maximum point of bounding box [x, y] */ maxPt: readonly number[] } /** * Parameters for filled pen drawing */ export interface DrawPenFilledParams { /** Array of points [[x, y, z], ...] defining the pen path */ penFillPts: number[][] /** Current slice orientation (0=axial, 1=coronal, 2=sagittal) */ penAxCorSag: number /** Drawing bitmap to modify */ drawBitmap: Uint8Array /** Volume dimensions [unused, dimX, dimY, dimZ, ...] */ dims: number[] /** Pen value (color index) to fill with */ penValue: number /** Whether fill should overwrite existing drawings */ fillOverwrites: boolean /** Current undo bitmap (RLE encoded) */ currentUndoBitmap: Uint8Array | null } /** * Result of filled pen drawing operation */ export interface DrawPenFilledResult { /** Updated drawing bitmap */ drawBitmap: Uint8Array /** Whether the operation was successful */ success: boolean } // ============================================================================ // Point Drawing Functions // ============================================================================ /** * Calculate the voxel index from x, y, z coordinates * @param x - X coordinate * @param y - Y coordinate * @param z - Z coordinate * @param dx - X dimension size * @param dy - Y dimension size * @returns Voxel index in flattened array */ export function voxelIndex(x: number, y: number, z: number, dx: number, dy: number): number { return x + y * dx + z * dx * dy } /** * Clamp a value to be within valid dimension bounds * @param value - Value to clamp * @param max - Maximum value (exclusive) * @returns Clamped value between 0 and max-1 */ export function clampToDimension(value: number, max: number): number { return Math.min(Math.max(value, 0), max - 1) } /** * Draw a single point in the drawing bitmap. * Handles pen size by drawing neighboring voxels in the current slice plane. * * @param params - Parameters for drawing the point */ export function drawPoint(params: DrawPointParams): void { const { x: inputX, y: inputY, z: inputZ, penValue, drawBitmap, dims, penSize, penAxCorSag } = params const dx = dims[1] const dy = dims[2] const dz = dims[3] // Clamp coordinates to valid range const x = clampToDimension(inputX, dx) const y = clampToDimension(inputY, dy) const z = clampToDimension(inputZ, dz) // Draw the center point drawBitmap[voxelIndex(x, y, z, dx, dy)] = penValue // Handle pen size > 1 if (penSize > 1) { const halfPenSize = Math.floor(penSize / 2) const isAx = penAxCorSag === PEN_SLICE_TYPE.AXIAL const isCor = penAxCorSag === PEN_SLICE_TYPE.CORONAL const isSag = penAxCorSag === PEN_SLICE_TYPE.SAGITTAL for (let i = -halfPenSize; i <= halfPenSize; i++) { for (let j = -halfPenSize; j <= halfPenSize; j++) { let nx: number, ny: number, nz: number if (isAx) { // Axial: vary x and y, keep z constant nx = clampToDimension(x + i, dx) ny = clampToDimension(y + j, dy) nz = z } else if (isCor) { // Coronal: vary x and z, keep y constant nx = clampToDimension(x + i, dx) ny = y nz = clampToDimension(z + j, dz) } else if (isSag) { // Sagittal: vary y and z, keep x constant nx = x ny = clampToDimension(y + j, dy) nz = clampToDimension(z + i, dz) } else { // Unknown orientation - just draw center point continue } drawBitmap[voxelIndex(nx, ny, nz, dx, dy)] = penValue } } } } // ============================================================================ // Line Drawing Functions (Bresenham's Algorithm) // ============================================================================ /** * Draw a 3D line between two points using Bresenham's line algorithm. * This algorithm efficiently draws lines in discrete voxel space. * * @param params - Parameters for drawing the line */ export function drawLine(params: DrawLineParams): void { const { ptA, ptB, penValue, drawBitmap, dims, penSize, penAxCorSag } = params const dx = Math.abs(ptA[0] - ptB[0]) const dy = Math.abs(ptA[1] - ptB[1]) const dz = Math.abs(ptA[2] - ptB[2]) // Determine step directions const xs = ptB[0] > ptA[0] ? 1 : -1 const ys = ptB[1] > ptA[1] ? 1 : -1 const zs = ptB[2] > ptA[2] ? 1 : -1 // Current position let x1 = ptA[0] let y1 = ptA[1] let z1 = ptA[2] // Target position const x2 = ptB[0] const y2 = ptB[1] const z2 = ptB[2] // Create params for drawing points along the line const pointParams: DrawPointParams = { x: 0, y: 0, z: 0, penValue, drawBitmap, dims, penSize, penAxCorSag } if (dx >= dy && dx >= dz) { // Driving axis is X-axis let p1 = 2 * dy - dx let p2 = 2 * dz - dx while (x1 !== x2) { x1 += xs if (p1 >= 0) { y1 += ys p1 -= 2 * dx } if (p2 >= 0) { z1 += zs p2 -= 2 * dx } p1 += 2 * dy p2 += 2 * dz pointParams.x = x1 pointParams.y = y1 pointParams.z = z1 drawPoint(pointParams) } } else if (dy >= dx && dy >= dz) { // Driving axis is Y-axis let p1 = 2 * dx - dy let p2 = 2 * dz - dy while (y1 !== y2) { y1 += ys if (p1 >= 0) { x1 += xs p1 -= 2 * dy } if (p2 >= 0) { z1 += zs p2 -= 2 * dy } p1 += 2 * dx p2 += 2 * dz pointParams.x = x1 pointParams.y = y1 pointParams.z = z1 drawPoint(pointParams) } } else { // Driving axis is Z-axis let p1 = 2 * dy - dz let p2 = 2 * dx - dz while (z1 !== z2) { z1 += zs if (p1 >= 0) { y1 += ys p1 -= 2 * dz } if (p2 >= 0) { x1 += xs p2 -= 2 * dz } p1 += 2 * dy p2 += 2 * dx pointParams.x = x1 pointParams.y = y1 pointParams.z = z1 drawPoint(pointParams) } } } // ============================================================================ // Flood Fill Functions // ============================================================================ /** * Fill exterior regions of a 2D bitmap using FIFO queue-based flood fill. * Marks outside voxels with value 2 while leaving interior voxels at 0 * and border voxels at 1. * * @param params - Parameters for the flood fill operation */ export function floodFillSection(params: FloodFillSectionParams): void { const { img2D, dims2D, minPt, maxPt } = params const w = dims2D[0] const [minX, minY] = minPt const [maxX, maxY] = maxPt // Allocate queue with capacity for worst case const capacity = 4 * (maxX - minX + maxY - minY + 2) const queue = new Int32Array(capacity * 2) // store x,y pairs let head = 0 let tail = 0 function enqueue(x: number, y: number): void { if (x < minX || x > maxX || y < minY || y > maxY) { return } const idx = x + y * w if (img2D[idx] !== 0) { return } img2D[idx] = 2 // mark visited/outside queue[tail] = x queue[tail + 1] = y tail = (tail + 2) % queue.length } function dequeue(): [number, number] | null { if (head === tail) { return null } const x = queue[head] const y = queue[head + 1] head = (head + 2) % queue.length return [x, y] } // Seed all edges for (let x = minX; x <= maxX; x++) { enqueue(x, minY) enqueue(x, maxY) } for (let y = minY + 1; y <= maxY - 1; y++) { enqueue(minX, y) enqueue(maxX, y) } // Flood fill let pt: [number, number] | null while ((pt = dequeue()) !== null) { const [x, y] = pt enqueue(x - 1, y) enqueue(x + 1, y) enqueue(x, y - 1) enqueue(x, y + 1) } } // ============================================================================ // 2D Line Drawing Helper (for filled pen) // ============================================================================ /** * Draw a 2D line in a bitmap using Bresenham's algorithm. * Used internally for filled pen operations. * * @param img2D - 2D image bitmap to draw on * @param dims2D - Dimensions [width, height] * @param ptA - Start point [x, y] * @param ptB - End point [x, y] * @param pen - Pen value to draw */ function drawLine2D(img2D: Uint8Array, dims2D: number[], ptA: number[], ptB: number[], pen: number): void { const dx = Math.abs(ptA[0] - ptB[0]) const dy = Math.abs(ptA[1] - ptB[1]) img2D[ptA[0] + ptA[1] * dims2D[0]] = pen img2D[ptB[0] + ptB[1] * dims2D[0]] = pen const xs = ptB[0] > ptA[0] ? 1 : -1 const ys = ptB[1] > ptA[1] ? 1 : -1 let x1 = ptA[0] let y1 = ptA[1] const x2 = ptB[0] const y2 = ptB[1] if (dx >= dy) { // Driving axis is X-axis let p1 = 2 * dy - dx while (x1 !== x2) { x1 += xs if (p1 >= 0) { y1 += ys p1 -= 2 * dx } p1 += 2 * dy img2D[x1 + y1 * dims2D[0]] = pen } } else { // Driving axis is Y-axis let p1 = 2 * dx - dy while (y1 !== y2) { y1 += ys if (p1 >= 0) { x1 += xs p1 -= 2 * dy } p1 += 2 * dx img2D[x1 + y1 * dims2D[0]] = pen } } } /** * Constrain a 2D point to be within dimension bounds * @param xy - Point [x, y] * @param dims2D - Dimensions [width, height] * @returns Constrained point [x, y] */ function constrainXY(xy: number[], dims2D: number[]): number[] { const x = Math.min(Math.max(xy[0], 0), dims2D[0] - 1) const y = Math.min(Math.max(xy[1], 0), dims2D[1] - 1) return [x, y] } /** * Get horizontal and vertical indices based on slice orientation * @param axCorSag - Slice orientation (0=axial, 1=coronal, 2=sagittal) * @returns [horizontal index, vertical index] */ export function getSliceIndices(axCorSag: number): [number, number] { // Default: axial is x(0) * y(1) horizontal*vertical let h = 0 let v = 1 if (axCorSag === 1) { // Coronal is x(0) * z(2) v = 2 } else if (axCorSag === 2) { // Sagittal is y(1) * z(2) h = 1 v = 2 } return [h, v] } // ============================================================================ // Filled Pen Drawing // ============================================================================ /** * Fill the interior of drawn pen line segments. * Connects and fills the interior of a closed polygon defined by pen strokes. * * @param params - Parameters for the filled pen operation * @returns Result containing updated bitmap and success flag */ export function drawPenFilled(params: DrawPenFilledParams): DrawPenFilledResult { const { penFillPts, penAxCorSag, drawBitmap, dims, penValue, fillOverwrites, currentUndoBitmap } = params const nPts = penFillPts.length if (nPts < 2) { // Cannot fill single line return { drawBitmap, success: false } } // Get horizontal and vertical indices based on slice orientation const [h, v] = getSliceIndices(penAxCorSag) // Create 2D dimensions (+1 because dims is indexed from 0) const dims2D = [dims[h + 1], dims[v + 1]] // Create 2D bitmap for flood fill const img2D = new Uint8Array(dims2D[0] * dims2D[1]) const pen = 1 // Use 1 for border (not penValue, as "erase" is zero) // Get start point and initialize tracking const startPt = constrainXY([penFillPts[0][h], penFillPts[0][v]], dims2D) let minPt = [...startPt] let maxPt = [...startPt] let prevPt = startPt // Draw all line segments in 2D for (let i = 1; i < nPts; i++) { let pt = [penFillPts[i][h], penFillPts[i][v]] pt = constrainXY(pt, dims2D) minPt = [Math.min(pt[0], minPt[0]), Math.min(pt[1], minPt[1])] maxPt = [Math.max(pt[0], maxPt[0]), Math.max(pt[1], maxPt[1])] drawLine2D(img2D, dims2D, prevPt, pt, pen) prevPt = pt } // Close the drawing by connecting last point to first drawLine2D(img2D, dims2D, startPt, prevPt, pen) // Add padding to bounds const pad = 1 minPt[0] = Math.max(0, minPt[0] - pad) minPt[1] = Math.max(0, minPt[1] - pad) maxPt[0] = Math.min(dims2D[0] - 1, maxPt[0] + pad) maxPt[1] = Math.min(dims2D[1] - 1, maxPt[1] + pad) // Mark exterior voxels that are outside the bounding box for (let y = 0; y < dims2D[1]; y++) { for (let x = 0; x < dims2D[0]; x++) { if (x >= minPt[0] && x < maxPt[0] && y >= minPt[1] && y <= maxPt[1]) { continue } const pxl = x + y * dims2D[0] if (img2D[pxl] !== 0) { continue } img2D[pxl] = 2 } } // Flood fill from edges to mark exterior const startTime = Date.now() floodFillSection({ img2D, dims2D, minPt, maxPt }) log.debug(`FloodFill ${Date.now() - startTime}`) // All voxels with value of zero have no path to edges (interior) // Insert surviving pixels from 2D bitmap into 3D bitmap const slice = penFillPts[0][3 - (h + v)] // Create a copy of the bitmap to modify const newDrawBitmap = new Uint8Array(drawBitmap) if (penAxCorSag === 0) { // Axial const offset = slice * dims2D[0] * dims2D[1] for (let i = 0; i < dims2D[0] * dims2D[1]; i++) { if (img2D[i] !== 2) { newDrawBitmap[i + offset] = penValue } } } else { let xStride = 1 // Coronal: horizontal LR pixels contiguous const yStride = dims[1] * dims[2] // Coronal: vertical is slice let zOffset = slice * dims[1] // Coronal: slice is number of columns if (penAxCorSag === 2) { // Sagittal xStride = dims[1] zOffset = slice } let i = 0 for (let y = 0; y < dims2D[1]; y++) { for (let x = 0; x < dims2D[0]; x++) { if (img2D[i] !== 2) { newDrawBitmap[x * xStride + y * yStride + zOffset] = penValue } i++ } } } // Handle non-overwriting fill mode - merge with previous state if (!fillOverwrites && currentUndoBitmap && currentUndoBitmap.length > 0) { const nv = newDrawBitmap.length const bmp = decodeRLE(currentUndoBitmap, nv) for (let i = 0; i < nv; i++) { if (bmp[i] === 0) { continue } newDrawBitmap[i] = bmp[i] } } return { drawBitmap: newDrawBitmap, success: true } } // ============================================================================ // Pen State Helpers // ============================================================================ /** * Check if pen location is valid (not NaN) * @param penLocation - Current pen location [x, y, z] * @returns True if pen location is valid */ export function isPenLocationValid(penLocation: number[]): boolean { return !isNaN(penLocation[0]) } /** * Check if two points are the same location * @param ptA - First point [x, y, z] * @param ptB - Second point [x, y, z] * @returns True if points are the same */ export function isSamePoint(ptA: number[], ptB: number[]): boolean { return ptA[0] === ptB[0] && ptA[1] === ptB[1] && ptA[2] === ptB[2] } /** * Create initial pen state for starting a new stroke * @returns Initial pen state values */ export function createInitialPenState(): { penLocation: number[]; penAxCorSag: number; penFillPts: number[][] } { return { penLocation: [NaN, NaN, NaN], penAxCorSag: -1, penFillPts: [] } } /** * Create reset pen state (for when drawing ends) * @returns Reset pen state values */ export function createResetPenState(): { penLocation: number[]; penAxCorSag: number } { return { penLocation: [NaN, NaN, NaN], penAxCorSag: -1 } }