@niivue/niivue
Version:
minimal webgl2 nifti image viewer
1,177 lines (1,144 loc) • 88.9 kB
text/typescript
import { vec3, vec4 } from 'gl-matrix'
import { v4 as uuidv4 } from '@lukeed/uuid'
import { log } from '@/logger'
import { NiivueObject3D } from '@/niivue-object3D'
import { ColorMap, LUT, cmapper, COLORMAP_TYPE } from '@/colortables'
import { NVMeshUtilities } from '@/nvmesh-utilities'
import { NVMeshLoaders } from '@/nvmesh-loaders'
import { NVLabel3D, LabelTextAlignment, LabelLineTerminator } from '@/nvlabel'
import { LegacyConnectome, LegacyNodes, NVConnectomeEdge, NVConnectomeNode, Point } from '@/types'
import { DefaultMeshType, GII, MZ3, TCK, TRACT, TRK, TT, TRX, VTK, ValuesArray, X3D, AnyNumberArray } from '@/nvmesh-types'
/** Enum for text alignment
*/
export enum MeshType {
MESH = 'mesh',
CONNECTOME = 'connectome',
FIBER = 'fiber'
}
export type NVMeshLayer = {
name?: string
key?: string
url?: string
headers?: Record<string, string>
opacity: number
colormap: string
colormapNegative?: string
colormapInvert?: boolean
colormapLabel?: ColorMap | LUT
useNegativeCmap?: boolean
global_min?: number
global_max?: number
cal_min: number
cal_max: number
cal_minNeg: number
cal_maxNeg: number
isAdditiveBlend?: boolean
frame4D: number
nFrame4D: number
values: AnyNumberArray // number[] | Float32Array | Uint32Array
outlineBorder?: number
isTransparentBelowCalMin?: boolean
colormapType?: number
base64?: string
// TODO referenced in niivue/refreshColormaps
colorbarVisible?: boolean
showLegend?: boolean
labels?: NVLabel3D[]
atlasValues?: AnyNumberArray
}
export const NVMeshLayerDefaults = {
colormap: 'gray',
opacity: 0.0,
nFrame4D: 0,
frame4D: 0,
outlineBorder: 0,
cal_min: 0,
cal_max: 0,
cal_minNeg: 0,
cal_maxNeg: 0,
colormapType: COLORMAP_TYPE.MIN_TO_MAX,
values: new Array<number>(),
useNegativeCmap: false,
showLegend: true
}
export class NVMeshFromUrlOptions {
url: string
gl: WebGL2RenderingContext | null
name: string
opacity: number
rgba255: Uint8Array
visible: boolean
layers: NVMeshLayer[]
colorbarVisible: boolean
meshShaderIndex: number
constructor(url = '', gl = null, name = '', opacity = 1.0, rgba255 = new Uint8Array([255, 255, 255, 255]), visible = true, layers = [], colorbarVisible = true, meshShaderIndex = 0) {
this.url = url
this.gl = gl
this.name = name
this.opacity = opacity
this.rgba255 = rgba255
this.visible = visible
this.layers = layers
this.colorbarVisible = colorbarVisible
this.meshShaderIndex = meshShaderIndex
}
}
/**
* Parameters for loading a base mesh or volume.
*/
type BaseLoadParams = {
/** WebGL rendering context. */
gl: WebGL2RenderingContext
/** Name for this image. Default is an empty string. */
name: string
/** Opacity for this image. Default is 1. */
opacity: number
/** Base color of the mesh in RGBA [0-255]. Default is white. */
rgba255: number[] | Uint8Array
/** Whether this image is visible. */
visible: boolean
/** Layers of the mesh to load. */
layers: NVMeshLayer[]
/** Shader index for mesh rendering. Default is 0 (Phong). */
meshShaderIndex: number
}
export type LoadFromUrlParams = Partial<BaseLoadParams> & {
// the resolvable URL pointing to a mesh to load
url: string
headers?: Record<string, string>
buffer?: ArrayBuffer
}
type LoadFromFileParams = BaseLoadParams & {
// the file object
file: Blob
}
type LoadFromBase64Params = BaseLoadParams & {
// the base64 encoded string
base64: string
}
/**
* a NVMesh encapsulates some mesh data and provides methods to query and operate on meshes
*/
export class NVMesh {
id: string
name: string
anatomicalStructurePrimary: string
colorbarVisible: boolean
furthestVertexFromOrigin: number
extentsMin: number | number[]
extentsMax: number | number[]
opacity: number
visible: boolean
meshShaderIndex = 0
offsetPt0: Uint32Array | null = null
colormapInvert = false
fiberGroupColormap: ColorMap | null = null
indexBuffer: WebGLBuffer
vertexBuffer: WebGLBuffer
vao: WebGLVertexArrayObject
vaoFiber: WebGLVertexArrayObject
pts: Float32Array
tris?: Uint32Array
layers: NVMeshLayer[]
type = MeshType.MESH
data_type?: string
rgba255: Uint8Array
fiberLength?: number
fiberLengths?: Uint32Array
fiberDensity?: Float32Array
fiberDither = 0.1
fiberColor = 'Global'
fiberDecimationStride = 1 // e.g. if 2 the 50% of streamlines visible, if 3 then 1/3rd
fiberSides = 5 // 1=streamline, 2=imposter, >2=mesh(cylinder with fiberSides sides)
fiberRadius = 0 // in mm, e.g. 3 means 6mm diameter fibers, ignored if fiberSides < 3
fiberOcclusion = 0 // value 0..1 to simulate ambient occlusion
f32PerVertex = 5 // MUST be 5 or 7: number of float32s per vertex DEPRECATED, future releases will ALWAYS be 5
dpsThreshold = NaN
fiberMask?: unknown[]
colormap?: ColorMap | LegacyConnectome | string | null
dpg?: ValuesArray | null
dps?: ValuesArray | null
dpv?: ValuesArray | null
groups?: ValuesArray | null
hasConnectome = false
connectome?: LegacyConnectome | string
// TODO this should somehow get aligned with connectome
indexCount?: number
vertexCount = 1
nodeScale = 4
edgeScale = 1
legendLineThickness = 0
showLegend = true
nodeColormap = 'warm'
edgeColormap = 'warm'
nodeColormapNegative?: string
edgeColormapNegative?: string
nodeMinColor?: number
nodeMaxColor?: number
edgeMin?: number
edgeMax?: number
nodes?: LegacyNodes | NVConnectomeNode[]
edges?: number[] | NVConnectomeEdge[]
points?: Point[]
/**
* @param pts - a 3xN array of vertex positions (X,Y,Z coordinates).
* @param tris - a 3xN array of triangle indices (I,J,K; indexed from zero). Each triangle generated from three vertices.
* @param name - a name for this image. Default is an empty string
* @param rgba255 - the base color of the mesh. RGBA values from 0 to 255. Default is white
* @param opacity - the opacity for this mesh. default is 1
* @param visible - whether or not this image is to be visible
* @param gl - WebGL rendering context
* @param connectome - specify connectome edges and nodes. Default is null (not a connectome).
* @param dpg - Data per group for tractography, see TRK format. Default is null (not tractograpgy)
* @param dps - Data per streamline for tractography, see TRK format. Default is null (not tractograpgy)
* @param dpv - Data per vertex for tractography, see TRK format. Default is null (not tractograpgy)
* @param groups - Groups for tractography, see TRK format. Default is null (not tractograpgy)
* @param colorbarVisible - does this mesh display a colorbar
* @param anatomicalStructurePrimary - region for mesh. Default is an empty string
*/
constructor(
pts: Float32Array,
tris: Uint32Array,
name = '',
rgba255 = new Uint8Array([255, 255, 255, 255]),
opacity = 1.0,
visible = true,
gl: WebGL2RenderingContext,
connectome: LegacyConnectome | string | null = null,
dpg: ValuesArray | null = null,
dps: ValuesArray | null = null,
dpv: ValuesArray | null = null,
groups: ValuesArray | null = null,
colorbarVisible = true,
anatomicalStructurePrimary = ''
) {
this.anatomicalStructurePrimary = anatomicalStructurePrimary
this.name = name
this.colorbarVisible = colorbarVisible
this.id = uuidv4()
const obj = NVMeshUtilities.getExtents(pts)
this.furthestVertexFromOrigin = obj.mxDx
this.extentsMin = obj.extentsMin
this.extentsMax = obj.extentsMax
this.opacity = opacity > 1.0 ? 1.0 : opacity // make sure opacity can't be initialized greater than 1 see: #107 and #117 on github
this.visible = visible
this.meshShaderIndex = 0
this.indexBuffer = gl.createBuffer()!
this.vertexBuffer = gl.createBuffer()!
this.vao = gl.createVertexArray()!
// the VAO binds the vertices and indices as well as describing the vertex layout
gl.bindVertexArray(this.vao)
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer)
gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer)
// vertex position: 3 floats X,Y,Z
gl.enableVertexAttribArray(0)
gl.enableVertexAttribArray(1)
const f32PerVertex = this.f32PerVertex
if (f32PerVertex !== 7) {
// n32
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 20, 0)
// vertex surface normal vector: (also three floats)
gl.vertexAttribPointer(1, 4, gl.BYTE, true, 20, 12)
// vertex color
gl.enableVertexAttribArray(2)
gl.vertexAttribPointer(2, 4, gl.UNSIGNED_BYTE, true, 20, 16)
} else {
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 28, 0)
// vertex surface normal vector: (also three floats)
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 28, 12)
// vertex color
gl.enableVertexAttribArray(2)
gl.vertexAttribPointer(2, 4, gl.UNSIGNED_BYTE, true, 28, 24)
}
gl.bindVertexArray(null) // https://stackoverflow.com/questions/43904396/are-we-not-allowed-to-bind-gl-array-buffer-and-vertex-attrib-array-to-0-in-webgl
this.vaoFiber = gl.createVertexArray()!
this.offsetPt0 = null
this.hasConnectome = false
this.colormapInvert = false
this.fiberGroupColormap = null
this.pts = pts
this.layers = []
this.type = MeshType.MESH
this.tris = tris
if (rgba255[3] < 1) {
this.rgba255 = rgba255
this.fiberLength = 2
this.fiberDither = 0.1
this.fiberColor = 'Global'
this.fiberDecimationStride = 1 // e.g. if 2 the 50% of streamlines visible, if 3 then 1/3rd
this.fiberMask = [] // provide method to show/hide specific fibers
this.colormap = connectome
this.dpg = dpg
this.dps = dps
this.dpv = dpv
this.groups = groups
if (dpg) {
this.initValuesArray(dpg)
}
if (dps) {
this.initValuesArray(dps)
}
if (dpv) {
this.initValuesArray(dpv)
}
if (groups) {
this.initValuesArray(groups)
}
this.offsetPt0 = new Uint32Array(tris)
this.tris = new Uint32Array(0)
this.updateFibers(gl)
// define VAO
gl.bindVertexArray(this.vaoFiber)
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer)
gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer)
// vertex position: 3 floats X,Y,Z
gl.enableVertexAttribArray(0)
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 16, 0)
// vertex color
gl.enableVertexAttribArray(1)
gl.vertexAttribPointer(1, 4, gl.UNSIGNED_BYTE, true, 16, 12)
gl.bindVertexArray(null) // https://stackoverflow.com/questions/43904396/are-we-not-allowed-to-bind-gl-array-buffer-and-vertex-attrib-array-to-0-in-webgl
return
} // if fiber not mesh
if (connectome) {
this.connectome = connectome
this.hasConnectome = true
const keysArray = Object.keys(connectome)
for (let i = 0, len = keysArray.length; i < len; i++) {
this[keysArray[i]] = connectome[keysArray[i]]
}
}
this.rgba255 = rgba255
this.updateMesh(gl)
}
initValuesArray(va: ValuesArray): ValuesArray {
for (let i = 0; i < va.length; i++) {
const mn = va[i].vals.reduce((acc, current) => Math.min(acc, current))
const mx = va[i].vals.reduce((acc, current) => Math.max(acc, current))
va[i].global_min = mn
va[i].global_max = mx
va[i].cal_min = mn
va[i].cal_max = mx
}
return va
}
// given streamlines (which webGL renders as a single pixel), extrude to cylinders
linesToCylinders(gl: WebGL2RenderingContext, posClrF32: Float32Array, indices: number[]): void {
// return Float32Array
// const posClrF32 four 32-bit components X,Y,Z,C where C is Uint32 with RGBA
function v4ToV3(v4: vec4): vec3 {
return vec3.fromValues(v4[0], v4[1], v4[2])
}
const primitiveRestart = Math.pow(2, 32) - 1 // for gl.UNSIGNED_INT
const n_count = indices.length
let n_line_vtx = 0
let n_streamlines = 0
// n.b. each streamline terminates with a `primitiveRestart`, even the final one
for (let i = 0; i < n_count; i++) {
if (indices[i] === primitiveRestart) {
n_streamlines++
continue
}
n_line_vtx++
}
const cyl_sides = this.fiberSides
// next: generate extruded cylinders
// npt is number of points (vertices) for cylinders
const npt = cyl_sides * n_line_vtx
const f32PerVertex = this.f32PerVertex // 7 if NormalXYZ is 3 floats, 5 if normalXYZ is packed into rgb32
if (f32PerVertex !== 5) {
throw Error('fiberSides > 1 requires f32PerVertex == 5')
}
const f32 = new Float32Array(npt * f32PerVertex) // Each vertex has 5 components: PosX, PosY, PosZ, NormalXYZ, RGBA32
const u8 = new Uint8Array(f32.buffer) // Each vertex has 7 components: PositionXYZ, NormalXYZ, RGBA32
let vtx = 0
//
// previous vector location
let prevV4 = vec4.create()
let currV4 = vec4.create()
let nextV4 = vec4.create()
const v1 = vec3.create()
let prevV2 = vec3.create()
let node = 0
const radius = this.fiberRadius
for (let i = 0; i < n_count; i++) {
const isLineEnd = indices[i] === primitiveRestart
if (isLineEnd && node < 1) {
continue
} // two restarts in a row!
let idx = indices[i] * 4 // each posClrF32 has 4 elements X,Y,Z,C
node++
if (node <= 1) {
// first vertex in a streamline, no previous vertex
prevV4 = vec4.fromValues(posClrF32[idx + 0], posClrF32[idx + 1], posClrF32[idx + 2], posClrF32[idx + 3])
currV4 = vec4.clone(prevV4)
if (i + 1 < n_count && indices[i + 1] !== primitiveRestart) {
idx = indices[i + 1] * 4
nextV4 = vec4.fromValues(posClrF32[idx + 0], posClrF32[idx + 1], posClrF32[idx + 2], posClrF32[idx + 3])
vec3.subtract(v1, v4ToV3(prevV4), v4ToV3(nextV4))
vec3.normalize(v1, v1) // principle axis of cylinder
prevV2 = NiivueObject3D.getFirstPerpVector(v1)
}
continue
}
if (isLineEnd) {
// last vertex of streamline, no next vertex
nextV4 = vec4.clone(currV4)
} else {
nextV4 = vec4.fromValues(posClrF32[idx + 0], posClrF32[idx + 1], posClrF32[idx + 2], posClrF32[idx + 3])
}
// mean direction at joint
// n.b. vec4 -> vec3 we ignore 4th dimension (color)
vec3.subtract(v1, v4ToV3(prevV4), v4ToV3(nextV4))
vec3.normalize(v1, v1) // principle axis of cylinder
// avoid twisted cylinders: ensure v2 as closely aligned with previous v2 as possible
// method simpler than Frenet–Serret apparatus
// https://math.stackexchange.com/questions/410530/find-closest-vector-to-a-which-is-perpendicular-to-b
// const v2 = NiivueObject3D.getFirstPerpVector(v1)
// 𝐷=𝐴×𝐵, and then 𝐶=𝐵×𝐷. 𝐶 is automatically orthogonal to 𝐵
const D = vec3.create()
vec3.cross(D, prevV2, v1)
const v2 = vec3.create()
vec3.cross(v2, v1, D)
prevV2 = vec3.clone(prevV2)
// the next line of code would create arbitrary v2 that might show twisting
// v2 = NiivueObject3D.getFirstPerpVector(v1)
// Get the second perp vector by cross product
const v3 = vec3.create()
vec3.cross(v3, v1, v2) // a unit length vector orthogonal to v1 and v2
vec3.normalize(v3, v3)
const vtxXYZ = vec3.create()
for (let j = 0; j < cyl_sides; j++) {
const c = Math.cos((j / cyl_sides) * 2 * Math.PI)
const s = Math.sin((j / cyl_sides) * 2 * Math.PI)
vtxXYZ[0] = radius * (c * v2[0] + s * v3[0])
vtxXYZ[1] = radius * (c * v2[1] + s * v3[1])
vtxXYZ[2] = radius * (c * v2[2] + s * v3[2])
vec3.add(vtxXYZ, v4ToV3(currV4), vtxXYZ)
const fidx = vtx * f32PerVertex
f32[fidx + 0] = vtxXYZ[0]
f32[fidx + 1] = vtxXYZ[1]
f32[fidx + 2] = vtxXYZ[2]
// compute normal
const n3 = vec3.create()
vec3.subtract(n3, vtxXYZ, v4ToV3(currV4))
vec3.normalize(n3, n3)
const fidxU8 = (fidx + 3) * 4 // 4 Uint8 per Float32
u8[fidxU8 + 0] = n3[0] * 127
u8[fidxU8 + 1] = n3[1] * 127
u8[fidxU8 + 2] = n3[2] * 127
// f32[fidx+3] = normal;
f32[fidx + 4] = currV4[3]
// u32[fidx+3] = 65555;
// u32[fidx+4] = 65555;
vtx++
}
prevV4 = vec4.clone(currV4)
currV4 = vec4.clone(nextV4)
if (isLineEnd) {
node = 0
}
}
// ntri = number of triangles
// each cylinder is composed of 2 * cyl_sides (e.g. triangular cylinder is 6 triangles)
// each streamline with n nodes has n-1 cylinders (fencepost)
// each triangle defined by three indices, each referring to a vertex
const nidx = (n_line_vtx - n_streamlines) * cyl_sides * 2 * 3
const idxs = new Uint32Array(nidx)
let idx = 0
vtx = 0
for (let i = 1; i < n_count; i++) {
if (indices[i] === primitiveRestart) {
vtx += cyl_sides
continue
}
if (indices[i - 1] === primitiveRestart) {
// fencepost: do not create indices for first node in each streamline
continue
}
let prevStartVtx = vtx // startOfPreviousCylinder
let startVtx = vtx + cyl_sides // startOfCurrentCylinder
const prevStartVtxOverflow = startVtx // startOfCurrentCylinder
const startVtxOverflow = startVtx + cyl_sides // startOfNextCylinder
for (let j = 0; j < cyl_sides; j++) {
// emit triangle with one vertex on previous
idxs[idx++] = prevStartVtx
idxs[idx++] = startVtx++
if (startVtx === startVtxOverflow) {
startVtx = startVtxOverflow - cyl_sides
}
idxs[idx++] = startVtx
// emit triangle with two vertex on previous
idxs[idx++] = prevStartVtx++
if (prevStartVtx === prevStartVtxOverflow) {
prevStartVtx = prevStartVtxOverflow - cyl_sides
}
idxs[idx++] = startVtx
idxs[idx++] = prevStartVtx
}
vtx += cyl_sides
}
// copy index and vertex buffer to GPU
// no need to release: https://registry.khronos.org/OpenGL-Refpages/gl4/html/glBufferData.xhtml
// any pre-existing data store is deleted
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer)
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, Uint32Array.from(idxs), gl.STATIC_DRAW)
gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer)
// issue1129
// gl.bufferData(gl.ARRAY_BUFFER, Float32Array.from(f32), gl.STATIC_DRAW)
gl.bufferData(gl.ARRAY_BUFFER, u8, gl.STATIC_DRAW)
this.indexCount = nidx
} // linesToCylinders
createFiberDensityMap(): void {
// generate a fiber density map
// the array fiberDensity has one element per vertex
// this provides the normalized (0..1) neighboring vertices
if (this.fiberDensity) {
return
}
const pts = this.pts
const npt = pts.length / 3 // each point has three components: X,Y,Z
let maxExtentsRange = 0
for (let i = 0; i < 3; i++) {
const range = this.extentsMax[i] - this.extentsMin[i]
maxExtentsRange = Math.max(maxExtentsRange, range)
}
this.fiberDensity = new Float32Array(npt)
if (maxExtentsRange === 0) {
return
}
// DSI-Studio counts vertex density per voxel
// However, some tract formats do not store voxel dimensions
// therefore, we will create a 3D volume of size bins*bins*bins
const bins = 64
const binWidth = maxExtentsRange / (bins - 1)
const half = binWidth / 2
const scale = (bins - 1) / maxExtentsRange
let densityMap = new Float32Array(bins * bins * bins)
const mn = [this.extentsMin[0] - half, this.extentsMin[1] - half, this.extentsMin[2] - half]
// sum density map
const xyz = [0, 0, 0]
const prevVx = -1
const binsXbins = bins * bins
let j = 0
for (let i = 0; i < npt; i++) {
xyz[0] = Math.round((pts[j++] - mn[0]) * scale)
xyz[1] = Math.round((pts[j++] - mn[1]) * scale)
xyz[2] = Math.round((pts[j++] - mn[2]) * scale)
const vx = xyz[0] + xyz[1] * bins + xyz[2] * binsXbins
if (vx === prevVx) {
// each streamline contributes once per voxel
continue
}
densityMap[vx]++
}
function blur3D(vol: Float32Array, dim: number): Float32Array {
// let raw = vol.slice()
let raw = vol.slice()
let v = -1
const dim1 = dim - 1
// blur in x
for (let z = 0; z < dim; z++) {
for (let y = 0; y < dim; y++) {
for (let x = 0; x < dim; x++) {
v++
if (x < 1 || x >= dim1) {
continue
}
vol[v] = raw[v - 1] + raw[v] + raw[v] + raw[v + 1]
}
}
}
// blur in y
v = -1
raw = vol.slice()
for (let z = 0; z < dim; z++) {
for (let y = 0; y < dim; y++) {
for (let x = 0; x < dim; x++) {
v++
if (y < 1 || y >= dim1) {
continue
}
vol[v] = raw[v - dim] + raw[v] + raw[v] + raw[v + dim]
}
}
}
// blur in z
const dimXdim = dim * dim
v = -1
raw = vol.slice()
for (let z = 0; z < dim; z++) {
for (let y = 0; y < dim; y++) {
for (let x = 0; x < dim; x++) {
v++
if (z < 1 || z >= dim1) {
continue
}
vol[v] = raw[v - dimXdim] + raw[v] + raw[v] + raw[dimXdim]
}
}
}
return vol
}
densityMap = blur3D(densityMap, bins)
densityMap = blur3D(densityMap, bins)
// let raw = densityMap.slice()
let mx = 0
let mn0 = Infinity
const binsXbinsXbins = bins * bins * bins
for (let i = 0; i < binsXbinsXbins; i++) {
if (densityMap[i] <= 0) {
continue
}
mx = Math.max(mx, densityMap[i])
mn0 = Math.min(mn0, densityMap[i])
}
// console.log('Maximum streamlines in a voxel:', mx, mn0)
if (mx <= 1 || mx <= mn0) {
// no neighbors: no ambient occlusion
return
}
j = 0
for (let i = 0; i < binsXbinsXbins; i++) {
// least occluded vertices should have no occlusion
densityMap[i] = Math.max(0, densityMap[i] - mn0)
}
mx -= mn0
for (let i = 0; i < npt; i++) {
xyz[0] = Math.round((pts[j++] - mn[0]) * scale)
xyz[1] = Math.round((pts[j++] - mn[1]) * scale)
xyz[2] = Math.round((pts[j++] - mn[2]) * scale)
const vx = xyz[0] + xyz[1] * bins + xyz[2] * binsXbins
this.fiberDensity[i] = densityMap[vx] / mx
}
}
// not included in public docs
// internal function filters tractogram to identify which color and visibility of streamlines
updateFibers(gl: WebGL2RenderingContext): void {
if (!this.offsetPt0 || !this.fiberLength) {
return
}
const pts = this.pts
const offsetPt0 = this.offsetPt0
const n_count = offsetPt0.length - 1
const npt = pts.length / 3 // each point has three components: X,Y,Z
// only once: compute length of each streamline
if (!this.fiberLengths) {
this.fiberLengths = new Uint32Array(n_count)
for (let i = 0; i < n_count; i++) {
// for each streamline
const vStart3 = offsetPt0[i] * 3 // first vertex in streamline
const vEnd3 = (offsetPt0[i + 1] - 1) * 3 // last vertex in streamline
let len = 0
for (let j = vStart3; j < vEnd3; j += 3) {
const v = vec3.fromValues(pts[j + 0] - pts[j + 3], pts[j + 1] - pts[j + 4], pts[j + 2] - pts[j + 5])
len += vec3.len(v)
}
this.fiberLengths[i] = len
}
} // only once: compute length of each streamline
// determine fiber colors
// Each streamline vertex has color and position attributes
// Interleaved Vertex Data https://developer.apple.com/library/archive/documentation/3DDrawing/Conceptual/OpenGLES_ProgrammingGuide/TechniquesforWorkingwithVertexData/TechniquesforWorkingwithVertexData.html
const posClrF32 = new Float32Array(npt * 4) // four 32-bit components X,Y,Z,C
const posClrU32 = new Uint32Array(posClrF32.buffer) // typecast of our X,Y,Z,C array
// fill XYZ position of XYZC array
let i3 = 0
let i4 = 0
for (let i = 0; i < npt; i++) {
posClrF32[i4 + 0] = pts[i3 + 0]
posClrF32[i4 + 1] = pts[i3 + 1]
posClrF32[i4 + 2] = pts[i3 + 2]
i3 += 3
i4 += 4
}
// fill fiber Color
const dither = this.fiberDither
const ditherHalf = dither * 0.5
function rgb2int32(r: number, g: number, b: number): number {
const ditherFrac = dither * Math.random()
const d = 255.0 * (ditherFrac - ditherHalf)
r = Math.max(Math.min(r + d, 255.0), 0.0)
g = Math.max(Math.min(g + d, 255.0), 0.0)
b = Math.max(Math.min(b + d, 255.0), 0.0)
return r + (g << 8) + (b << 16)
}
function direction2rgb(x1: number, y1: number, z1: number, x2: number, y2: number, z2: number, ditherFrac: number): number {
// generate color based on direction between two 3D spatial positions
const v = vec3.fromValues(Math.abs(x1 - x2), Math.abs(y1 - y2), Math.abs(z1 - z2))
vec3.normalize(v, v)
const r = ditherFrac - ditherHalf
for (let j = 0; j < 3; j++) {
v[j] = 255 * Math.max(Math.min(Math.abs(v[j]) + r, 1.0), 0.0)
}
return v[0] + (v[1] << 8) + (v[2] << 16)
} // direction2rgb()
// Determine color: local, global, dps0, dpv0, etc.
const fiberColor = this.fiberColor.toLowerCase()
let dps: Float32Array | null = null
let dpv: ValuesArray[0] | null = null
if (fiberColor.startsWith('dps') && this.dps && this.dps.length > 0) {
const n = parseInt(fiberColor.substring(3))
if (n < this.dps.length && this.dps[n].vals.length === n_count) {
dps = this.dps[n].vals
}
}
if (fiberColor.startsWith('dpv') && this.dpv && this.dpv.length > 0) {
const n = parseInt(fiberColor.substring(3))
if (n < this.dpv.length && this.dpv[n].vals.length === npt) {
dpv = this.dpv[n]
}
}
const streamlineVisible = new Int16Array(n_count)
if ((this.groups && this.fiberGroupColormap !== null) || (fiberColor.startsWith('dpg') && this.dpg.length > 0)) {
const lut = new Uint8ClampedArray(this.groups.length * 4) // 4 component RGBA for each group
const groupVisible = new Array(this.groups.length).fill(false)
if (this.fiberGroupColormap) {
const cmap = this.fiberGroupColormap
if (cmap.A === undefined) {
cmap.A = Array.from(new Uint8ClampedArray(cmap.I.length).fill(255))
}
for (let i = 0; i < cmap.I.length; i++) {
let idx = cmap.I[i]
if (idx < 0 || idx >= this.groups.length) {
continue
}
if (cmap.A[i] < 1) {
continue
}
groupVisible[idx] = true
idx *= 4
lut[idx] = cmap.R[i]
lut[idx + 1] = cmap.G[i]
lut[idx + 2] = cmap.B[i]
lut[idx + 3] = 255 // opaque
}
} else {
if (fiberColor.startsWith('dpg') && this.dpg.length > 0) {
const n = parseInt(fiberColor.substring(3))
const dpg = n < this.dpg.length ? this.dpg[n] : this.dpg[0]
const lut255 = cmapper.colormap(this.colormap as string, this.colormapInvert)
const mn = dpg.cal_min
const mx = dpg.cal_max
const ngroup = this.groups.length
for (let i = 0; i < ngroup; i++) {
const v = dpg.vals[i]
if (v < mn) {
continue
}
let color255 = Math.round(255 * Math.min(Math.max((v - mn!) / (mx! - mn!), 0), 1))
groupVisible[i] = true
const idx = i * 4
// read RGB values from chosen colormap
color255 *= 4
lut[idx] = lut255[color255 + 0]
lut[idx + 1] = lut255[color255 + 1]
lut[idx + 2] = lut255[color255 + 2]
lut[idx + 3] = 255 // opaque
}
}
}
streamlineVisible.fill(-1) // -1 assume streamline not visible
for (let i = 0; i < this.groups.length; i++) {
if (!groupVisible[i]) {
continue
} // this group is not visible
const group = this.groups[i]
for (let v = 0; v < group.vals.length; v++) {
streamlineVisible[group.vals[v]] = i
}
}
for (let i = 0; i < n_count; i++) {
if (streamlineVisible[i] < 0) {
continue
} // hidden
const color = (streamlineVisible[i] % 256) * 4
// let RGBA = lut[color] + (lut[color + 1] << 8) + (lut[color + 2] << 16);
const RGBA = rgb2int32(lut[color], lut[color + 1], lut[color + 2])
const vStart = offsetPt0[i] // first vertex in streamline
const vEnd = offsetPt0[i + 1] - 1 // last vertex in streamline
const vStart4 = vStart * 4 + 3 // +3: fill 4th component colors: XYZC = 0123
const vEnd4 = vEnd * 4 + 3
for (let j = vStart4; j <= vEnd4; j += 4) {
posClrU32[j] = RGBA
}
}
} else if (dpv) {
// color per vertex
const lut = cmapper.colormap(this.colormap as string, this.colormapInvert)
const mn = dpv.cal_min
const mx = dpv.cal_max
let v4 = 3 // +3: fill 4th component colors: XYZC = 0123
for (let i = 0; i < npt; i++) {
let color = Math.min(Math.max((dpv.vals[i] - mn!) / (mx! - mn!), 0), 1)
color = Math.round(Math.max(Math.min(255, color * 255))) * 4
const RGBA = lut[color] + (lut[color + 1] << 8) + (lut[color + 2] << 16)
posClrU32[v4] = RGBA
v4 += 4
}
} else if (dps) {
const lut = cmapper.colormap(this.colormap as string, this.colormapInvert)
let mn = dps[0]
let mx = dps[0]
for (let i = 0; i < n_count; i++) {
mn = Math.min(mn, dps[i])
mx = Math.max(mx, dps[i])
}
if (mx === mn) {
mn -= 1
} // avoid divide by zero
for (let i = 0; i < n_count; i++) {
let color = (dps[i] - mn) / (mx - mn)
color = Math.round(Math.max(Math.min(255, color * 255))) * 4
const RGBA = lut[color] + (lut[color + 1] << 8) + (lut[color + 2] << 16)
const vStart = offsetPt0[i] // first vertex in streamline
const vEnd = offsetPt0[i + 1] - 1 // last vertex in streamline
const vStart4 = vStart * 4 + 3 // +3: fill 4th component colors: XYZC = 0123
const vEnd4 = vEnd * 4 + 3
for (let j = vStart4; j <= vEnd4; j += 4) {
posClrU32[j] = RGBA
}
}
} else if (fiberColor.includes('fixed')) {
if (dither === 0.0) {
const RGBA = this.rgba255[0] + (this.rgba255[1] << 8) + (this.rgba255[2] << 16)
let v4 = 3 // +3: fill 4th component colors: XYZC = 0123
for (let i = 0; i < npt; i++) {
posClrU32[v4] = RGBA
v4 += 4
}
} else {
for (let i = 0; i < n_count; i++) {
const RGBA = rgb2int32(this.rgba255[0], this.rgba255[1], this.rgba255[2])
const vStart = offsetPt0[i] // first vertex in streamline
const vEnd = offsetPt0[i + 1] - 1 // last vertex in streamline
const vStart4 = vStart * 4 + 3 // +3: fill 4th component colors: XYZC = 0123
const vEnd4 = vEnd * 4 + 3
for (let j = vStart4; j <= vEnd4; j += 4) {
posClrU32[j] = RGBA
}
}
} // else fixed with dither
} else if (fiberColor.includes('local')) {
for (let i = 0; i < n_count; i++) {
// for each streamline
const vStart = offsetPt0[i] // first vertex in streamline
const vEnd = offsetPt0[i + 1] - 1 // last vertex in streamline
let v3 = vStart * 3 // pts have 3 components XYZ
const vEnd3 = vEnd * 3
const ditherFrac = dither * Math.random() // same dither amount throughout line
// for first point, we do not have a prior sample
let RGBA = direction2rgb(pts[v3], pts[v3 + 1], pts[v3 + 2], pts[v3 + 4], pts[v3 + 5], pts[v3 + 6], ditherFrac)
let v4 = vStart * 4 + 3 // +3: fill 4th component colors: XYZC = 0123
while (v3 < vEnd3) {
posClrU32[v4] = RGBA
v4 += 4 // stride is 4 32-bit values: float32 XYZ and 32-bit rgba
v3 += 3 // read next vertex
// direction estimated based on previous and next vertex
RGBA = direction2rgb(pts[v3 - 3], pts[v3 - 2], pts[v3 - 1], pts[v3 + 3], pts[v3 + 4], pts[v3 + 5], ditherFrac)
}
posClrU32[v4] = posClrU32[v4 - 4]
}
} else {
// if color is local direction, else global
for (let i = 0; i < n_count; i++) {
// for each streamline
const vStart = offsetPt0[i] // first vertex in streamline
const vEnd = offsetPt0[i + 1] - 1 // last vertex in streamline
const vStart3 = vStart * 3 // pts have 3 components XYZ
const vEnd3 = vEnd * 3
const RGBA = direction2rgb(pts[vStart3], pts[vStart3 + 1], pts[vStart3 + 2], pts[vEnd3], pts[vEnd3 + 1], pts[vEnd3 + 2], dither * Math.random())
const vStart4 = vStart * 4 + 3 // +3: fill 4th component colors: XYZC = 0123
const vEnd4 = vEnd * 4 + 3
for (let j = vStart4; j <= vEnd4; j += 4) {
posClrU32[j] = RGBA
}
}
}
// SHADING: ambient occlusion
if (this.fiberOcclusion > 0) {
this.createFiberDensityMap()
function shadeRGBA(rgba: number, frac: number): number {
const r = frac * (rgba & 0xff)
const g = frac * ((rgba >> 8) & 0xff)
const b = frac * ((rgba >> 16) & 0xff)
return r + (g << 8) + (b << 16)
}
for (let i = 0; i < n_count; i++) {
// for each streamline
const vStart = offsetPt0[i] // first vertex in streamline
const vEnd = offsetPt0[i + 1] - 1 // last vertex in streamline
const vStart4 = vStart * 4 + 3 // +3: fill 4th component colors: XYZC = 0123
const vEnd4 = vEnd * 4 + 3
let vtx = vStart
const bias = Math.min(this.fiberOcclusion, 0.99)
for (let j = vStart4; j <= vEnd4; j += 4) {
let shade = this.fiberDensity[vtx++]
if (shade <= 0) {
continue
}
// Schlick's fast bias function
// https://github.com/ayamflow/schlick-curve
shade = shade / ((1.0 / bias - 2.0) * (1.0 - shade) + 1.0)
const frac = 1 - Math.min(shade, 0.9)
// console.log(shade, frac)
let RGBA = posClrU32[j]
RGBA = shadeRGBA(RGBA, frac)
posClrU32[j] = RGBA
}
}
}
// issue 1426
if (Number.isFinite(this.dpsThreshold) && this.dps) {
if (!dps) {
// if color is not based on dps, threshold on first dps
const n = 0
if (this.dps[n].vals.length === n_count) {
dps = this.dps[n].vals
}
}
if (dps) {
for (let i = 0; i < n_count; i++) {
if (dps[i] < this.dpsThreshold) {
streamlineVisible[i] = -1
}
}
}
}
// INDICES:
const min_mm = this.fiberLength
// https://blog.spacepatroldelta.com/a?ID=00950-d878555f-a97a-4e32-9f40-fd9a449cb4fe
const primitiveRestart = Math.pow(2, 32) - 1 // for gl.UNSIGNED_INT
const indices: number[] = []
let stride = -1
for (let i = 0; i < n_count; i++) {
// let n_pts = offsetPt0[i + 1] - offsetPt0[i]; //if streamline0 starts at point 0 and streamline1 at point 4, then streamline0 has 4 points: 0,1,2,3
if (streamlineVisible[i] < 0) {
continue
}
if (this.fiberLengths[i] < min_mm) {
continue
}
stride++
if (stride % this.fiberDecimationStride !== 0) {
continue
} // e.g. if stride is 2 then half culled
for (let j = offsetPt0[i]; j < offsetPt0[i + 1]; j++) {
indices.push(j)
}
indices.push(primitiveRestart)
}
if (this.fiberSides > 2 && this.fiberRadius > 0) {
this.linesToCylinders(gl, posClrF32, indices)
} else {
// copy streamlines to GPU
this.indexCount = indices.length
gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer)
gl.bufferData(gl.ARRAY_BUFFER, Uint32Array.from(posClrU32), gl.STATIC_DRAW)
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer)
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, Uint32Array.from(indices), gl.STATIC_DRAW)
}
} // updateFibers()
// given X,Y,Z coordinates in world space, return index of nearest vertex as well as
// the distance of this closest vertex to the coordinates
indexNearestXYZmm(Xmm: number, Ymm: number, Zmm: number): number[] {
const pts = this.pts
const nvtx = this.pts.length / 3
let i = 0
let mnDx = Infinity
let mnIdx = 0
for (let j = 0; j < nvtx; j++) {
const dx = Math.pow(pts[i] - Xmm, 2) + Math.pow(pts[i + 1] - Ymm, 2) + Math.pow(pts[i + 2] - Zmm, 2)
if (dx < mnDx) {
mnDx = dx
mnIdx = j
}
i += 3
}
// Pythagorean theorem sqrt(x^2+y^2+z^2)
// only calculate sqrt once
mnDx = Math.sqrt(mnDx)
return [mnIdx, mnDx]
} // indexNearestXYZmm()
// internal function discards GPU resources
unloadMesh(gl: WebGL2RenderingContext): void {
// free WebGL resources
gl.bindBuffer(gl.ARRAY_BUFFER, null)
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null)
gl.bindVertexArray(null)
gl.deleteBuffer(this.vertexBuffer)
gl.deleteBuffer(this.indexBuffer)
gl.deleteVertexArray(this.vao)
gl.deleteVertexArray(this.vaoFiber)
// presumably, if we null the mesh we dereference all the arrays, or do we have to explicitly null arrays
this.offsetPt0 = null
this.tris = null
this.pts = null
if (this.layers && this.layers.length > 0) {
for (let i = 0; i < this.layers.length; i++) {
this.layers[i].values = null
}
}
if (this.dpg && this.dpg.length > 0) {
for (let i = 0; i < this.dpg.length; i++) {
this.dpg[i].vals = null
}
}
if (this.dps && this.dps.length > 0) {
for (let i = 0; i < this.dps.length; i++) {
this.dps[i].vals = null
}
}
}
// apply color lookup table to convert scalar array to RGBA array
scalars2RGBA(rgba: Uint8ClampedArray, layer: NVMeshLayer, scalars: AnyNumberArray, isNegativeCmap: boolean = false): Uint8ClampedArray {
const nValues = scalars.length
if (4 * nValues < rgba.length) {
log.error(`colormap2RGBA incorrectly specified ${nValues}*4 != ${rgba.length}`)
return rgba
}
const opa255 = Math.round(layer.opacity * 255)
let mn = layer.cal_min
let mx = layer.cal_max
let lut = cmapper.colormap(layer.colormap as string, this.colormapInvert)
let flip = 1
if (isNegativeCmap) {
if (!layer.useNegativeCmap) {
return rgba
}
flip = -1
lut = cmapper.colormap(layer.colormapNegative, layer.colormapInvert)
mn = layer.cal_min
mx = layer.cal_max
if (isFinite(layer.cal_minNeg) && isFinite(layer.cal_minNeg)) {
mn = -layer.cal_minNeg
mx = -layer.cal_maxNeg
}
}
let mnCal = mn
if (!layer.isTransparentBelowCalMin) {
mnCal = Number.NEGATIVE_INFINITY
}
const isTranslucentBelowMin = layer.colormapType === COLORMAP_TYPE.ZERO_TO_MAX_TRANSLUCENT_BELOW_MIN
if (layer.colormapType !== COLORMAP_TYPE.MIN_TO_MAX) {
mn = Math.min(mn, 0.0)
}
const scale255 = 255.0 / (mx - mn)
for (let j = 0; j < nValues; j++) {
let v = scalars[j] * flip
if (isNaN(v)) {
continue
}
let opa = opa255
if (v < mnCal) {
if (v > 0 && isTranslucentBelowMin) {
opa = Math.round(layer.opacity * 255 * Math.pow(v / mnCal, 2.0))
} else {
continue
}
}
v = (v - mn) * scale255
if (v < 0 && layer.isTransparentBelowCalMin) {
continue
}
v = Math.min(255, Math.max(0, Math.round(v))) * 4
const idx = j * 4
rgba[idx + 0] = lut[v + 0]
rgba[idx + 1] = lut[v + 1]
rgba[idx + 2] = lut[v + 2]
rgba[idx + 3] = opa
}
return rgba
}
blendColormap(u8: Uint8Array, additiveRGBA: Uint8Array, layer: NVMeshLayer, mn: number, mx: number, lut: Uint8ClampedArray, invert: boolean = false): void {
const nvtx = this.pts.length / 3
const opacity = Math.min(layer.opacity, 1.0)
function lerp(x: number, y: number, a: number): number {
// https://www.khronos.org/registry/OpenGL-Refpages/gl4/html/mix.xhtml
return x * (1 - a) + y * a
}
function additiveBlend(x: number, y: number): number {
return Math.min(x + y, 255.0)
}
const scaleFlip = invert ? -1 : 1
const frame = Math.min(Math.max(layer.frame4D, 0), layer.nFrame4D - 1)
const frameOffset = nvtx * frame
let mnCal = mn
if (!layer.isTransparentBelowCalMin) {
mnCal = Number.NEGATIVE_INFINITY
}
if (layer.colormapType !== COLORMAP_TYPE.MIN_TO_MAX) {
mn = Math.min(mn, 0.0)
}
const scale255 = 255.0 / (mx - mn)
// create border map for optional outline
let borders = new Array(nvtx).fill(false)
if (layer.outlineBorder !== 0.0) {
const v255s = new Uint8Array(nvtx).fill(0)
for (let j = 0; j < nvtx; j++) {
const v = scaleFlip * layer.values[j + frameOffset]
if (v >= mnCal) {
v255s[j] = 1
}
}
borders = NVMeshUtilities.getClusterBoundaryU8(v255s, this.tris)
for (let j = 0; j < nvtx; j++) {
const v = scaleFlip * layer.values[j + frameOffset]
if (v < mnCal) {
borders[j] = false
}
}
}
// create lookup table for translucency
const alphas = new Float32Array(256).fill(opacity)
if (mnCal > mn && layer.colormapType === COLORMAP_TYPE.ZERO_TO_MAX_TRANSLUCENT_BELOW_MIN) {
let minOpaque = Math.round((mnCal - mn) * scale255)
minOpaque = Math.max(minOpaque, 1)
for (let j = 1; j < minOpaque; j++) {
alphas[j] = opacity * Math.pow(j / minOpaque, 2.0)
}
alphas[0] = 0
mnCal = mn + Number.EPSILON
}
for (let j = 0; j < nvtx; j++) {
const v = scaleFlip * layer.values[j + frameOffset]
if (v < mnCal) {
continue
}
let v255 = Math.round((v - mn) * scale255)
if (v255 < 0 && layer.isTransparentBelowCalMin) {
continue
}
v255 = Math.max(0.0, v255)
v255 = Math.min(255.0, v255)
let opa = alphas[v255]
v255 *= 4
let vtx = j * 28 + 24 // posNormClr is 28 bytes stride, RGBA color at offset 24,
if (this.f32PerVertex !== 7) {
vtx = j * 20 + 16
}
if (layer.isAdditiveBlend) {
const j4 = j * 4
// sum red, green and blue layers
additiveRGBA[j4 + 0] = additiveBlend(additiveRGBA[j4 + 0], lut[v255 + 0])
additiveRGBA[j4 + 1] = additiveBlend(additiveRGBA[j4 + 1], lut[v255 + 1])
additiveRGBA[j4 + 2] = additiveBlend(additiveRGBA[j4 + 2], lut[v255 + 2])
additiveRGBA[j4 + 3] = additiveBlend(additiveRGBA[j4 + 3], 255.0)
} else {
if (borders[j]) {
opa = layer.outlineBorder
if (layer.outlineBorder < 0) {
u8[vtx + 0] = 0
u8[vtx + 1] = 0
u8[vtx + 2] = 0
continue
}
}
u8[vtx + 0] = lerp(u8[vtx + 0], lut[v255 + 0], opa)
u8[vtx + 1] = lerp(u8[vtx + 1], lut[v255 + 1], opa)
u8[vtx + 2] = lerp(u8[vtx + 2], lut[v255 + 2], opa)
}
}
} // blendColormap()
// internal function filters mesh to identify which color of triangulated mesh vertices
updateMesh(gl: WebGL2Rend