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 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(), 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 & { // the resolvable URL pointing to a mesh to load url: string headers?: Record 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: WebGL2RenderingContext): void { if (this.offsetPt0) { this.updateFibers(gl) return // fiber not mesh } if (this.hasConnectome) { // this.updateConnectome(gl) return // connectome not mesh } if (!this.pts || !this.tris || !this.rgba255) { log.warn('underspecified mesh') return } 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 } const posNormClr = this.generatePosNormClr(this.pts, this.tris, this.rgba255) const nvtx = this.pts.length / 3 const u8 = new Uint8Array(posNormClr.buffer) // Each vertex has 7 components: PositionXYZ, NormalXYZ, RGBA32 // create emission values // let posNormClrEmission = posNormClr.slice(); let maxAdditiveBlend = 0 const additiveRGBA = new Uint8Array(nvtx * 4) // emission let tris = this.tris if (this.layers && this.layers.length > 0) { for (let i = 0; i < this.layers.length; i++) { const layer = this.layers[i] const opacity = layer.opacity if (opacity <= 0.0 || layer.cal_min > layer.cal_max) { continue } if (layer.outlineBorder === undefined) { layer.outlineBorder = 0 } if (layer.isAdditiveBlend === undefined) { layer.isAdditiveBlend = false } // build a label colormap if (layer.colormapLabel && (layer.colormapLabel as ColorMap).R && !(layer.colormapLabel as LUT).lut) { // convert colormap JSON to RGBA LUT layer.colormapLabel = cmapper.makeLabelLut(layer.colormapLabel as ColorMap, 255, layer.global_max) } if (layer.colormapLabel && (layer.colormapLabel as LUT).lut) { const colormapLabel = layer.colormapLabel as LUT let minv = 0 if (layer.colormapLabel.min) { minv = layer.colormapLabel.min } let lut = colormapLabel.lut const nLabel = Math.floor(lut.length / 4) if (layer.atlasValues && nLabel > 0 && nLabel === layer.atlasValues.length && layer.colormap) { const atlasValues = layer.atlasValues let hasNaN = false let onlyNaN = true for (let j = 0; j < nLabel; j++) { if (isNaN(atlasValues[j])) { hasNaN = true } else { onlyNaN = false } } if (onlyNaN) { log.debug(`invisible mesh: all atlasValues are NaN.`) return } if (hasNaN) { log.debug(`some vertices have NaN atlasValues (mesh will be decimated).`) // First: identify all vertices mapped to NaN const nanVtxs = new Array(nvtx).fill(false) for (let j = 0; j < nvtx; j++) { const v = Math.round(layer.values[j]) - minv if (isNaN(atlasValues[v])) { nanVtxs[j] = true } } // next: find all triangle indices that have NaN vertices const nanIdxs = new Array(tris.length).fill(false) for (let j = 0; j < tris.length; j++) { if (nanVtxs[tris[j]]) { nanIdxs[j] = true } } // each triangle has 3 indices const trisIn = this.tris let nTriOK = 0 for (let j = 0; j < trisIn.length; j += 3) { if (!nanIdxs[j] && !nanIdxs[j + 1] && !nanIdxs[j + 2]) { nTriOK++ } } if (nTriOK === 0) { log.debug(`invisible mesh: all triangles of a vertex with a NaN atlasValue.`) } tris = new Uint32Array(nTriOK * 3) let k = 0 for (let j = 0; j < trisIn.length; j += 3) { if (!nanIdxs[j] && !nanIdxs[j + 1] && !nanIdxs[j + 2]) { tris[k++] = trisIn[j] tris[k++] = trisIn[j + 1] tris[k++] = trisIn[j + 2] } } } lut.fill(0) // make all transparent lut = this.scalars2RGBA(lut, layer, atlasValues) if (layer.useNegativeCmap) { lut = this.scalars2RGBA(lut, layer, atlasValues, true) } } else if (layer.atlasValues) { log.warn(`Expected ${nLabel} atlasValues but got ${layer.atlasValues.length} for mesh layer`) } // create labels for legend if (layer.showLegend && nLabel === layer.colormapLabel.labels.length) { layer.labels = [] for (let j = 0; j < nLabel; j++) { const rgba = Array.from(lut.slice(j * 4, j * 4 + 4)).map((v) => v / 255) const labelName = layer.colormapLabel.labels[j] // xyzMM is the center of mass for the label // For folded cortical regions, this point often lies within the volume const xyzMM = [0, 0, 0] let count = 0 for (let i = 0; i < nvtx; i++) { if (layer.values[i] === j) { const idx = i * 3 xyzMM[0] += this.pts[idx] xyzMM[1] += this.pts[idx + 1] xyzMM[2] += this.pts[idx + 2] count++ } } if (count > 0) { xyzMM[0] /= count xyzMM[1] /= count xyzMM[2] /= count } if ( rgba[3] === 0 || !labelName || // handles empty string, null, undefined labelName.startsWith('_') ) { continue } rgba[3] = 1 const label = new NVLabel3D( labelName, { textColor: rgba, bulletScale: 1, bulletColor: rgba, lineWidth: 0, lineColor: rgba, textScale: 1.0, textAlignment: LabelTextAlignment.LEFT, lineTerminator: LabelLineTerminator.NONE }, xyzMM ) layer.labels.push(label) log.debug('label for mesh layer:', label) } // for each label } else { delete layer.labels } const frame = Math.min(Math.max(layer.frame4D, 0), layer.nFrame4D - 1) const frameOffset = nvtx * frame const rgba8 = new Uint8Array(nvtx * 4) let k = 0 for (let j = 0; j < nvtx; j++) { // eslint-disable-next-line const v = layer.values[j + frameOffset] - minv const idx = 4 * Math.min(Math.max(v, 0), nLabel - 1) rgba8[k + 0] = lut[idx + 0] rgba8[k + 1] = lut[idx + 1] rgba8[k + 2] = lut[idx + 2] rgba8[k + 3] = Math.round((lut[idx + 3] / 255) * layer.opacity * 255) k += 4 } let opaque = new Array(nvtx).fill(false) if (layer.outlineBorder !== 0.0) { opaque = NVMeshUtilities.getClusterBoundary(rgba8, this.tris) } k = 0 for (let j = 0; j < nvtx; j++) { let vtx = j * 28 + 24 // posNormClr is 28 bytes stride, RGBA color at offset 24, if (this.f32PerVertex !== 7) { vtx = j * 20 + 16 } let opa = rgba8[k + 3] / 255 if (opaque[j]) { opa = layer.outlineBorder if (layer.outlineBorder < 0) { u8[vtx + 0] = 0 u8[vtx + 1] = 0 u8[vtx + 2] = 0 k += 4 continue } } u8[vtx + 0] = lerp(u8[vtx + 0], rgba8[k + 0], opa) u8[vtx + 1] = lerp(u8[vtx + 1], rgba8[k + 1], opa) u8[vtx + 2] = lerp(u8[vtx + 2], rgba8[k + 2], opa) k += 4 } // for each vertex continue } // if colormapLabel if (layer.values instanceof Uint8Array) { const rgba8 = new Uint8Array(layer.values.buffer) let opaque = new Array(nvtx).fill(true) if (layer.outlineBorder !== 0) { opaque = NVMeshUtilities.getClusterBoundary(rgba8, this.tris) } let k = 0 for (let j = 0; j < layer.values.length; j++) { let vtx = j * 28 + 24 // posNormClr is 28 bytes stride, RGBA color at offset 24, if (this.f32PerVertex !== 7) { vtx = j * 20 + 16 } let opa = opacity if (opaque[j]) { opa = layer.outlineBorder if (layer.outlineBorder < 0) { u8[vtx + 0] = 0 u8[vtx + 1] = 0 u8[vtx + 2] = 0 k += 4 continue } } u8[vtx + 0] = lerp(u8[vtx + 0], rgba8[k + 0], opa) u8[vtx + 1] = lerp(u8[vtx + 1], rgba8[k + 1], opa) u8[vtx + 2] = lerp(u8[vtx + 2], rgba8[k + 2], opa) k += 4 } continue } if (layer.useNegativeCmap) { layer.cal_min = Math.max(Number.EPSILON, layer.cal_min) layer.cal_max = Math.max(layer.cal_min + 0.000001, layer.cal_max) } if (layer.isTransparentBelowCalMin === undefined) { layer.isTransparentBelowCalMin = true } const lut = cmapper.colormap(layer.colormap, layer.colormapInvert) if (layer.isAdditiveBlend) { maxAdditiveBlend++ } this.blendColormap(u8, additiveRGBA, layer, layer.cal_min, layer.cal_max, lut) if (layer.useNegativeCmap) { const neglut = cmapper.colormap(layer.colormapNegative, layer.colormapInvert) let mn = layer.cal_min let mx = layer.cal_max if (isFinite(layer.cal_minNeg) && isFinite(layer.cal_minNeg)) { mn = -layer.cal_minNeg mx = -layer.cal_maxNeg } this.blendColormap(u8, additiveRGBA, layer, mn, mx, neglut, true) } } } if (maxAdditiveBlend > 0) { for (let j = 0; j < nvtx; j++) { let vtx = j * 28 + 24 // posNormClr is 28 bytes stride, RGBA color at offset 24, if (this.f32PerVertex !== 7) { vtx = j * 20 + 16 } const v = j * 4 // additiveRGBA is 4 bytes stride, RGBA color at offset 0, const opacity = Math.min(maxAdditiveBlend, additiveRGBA[v + 3] / 255) if (opacity <= 0) { continue } function modulate(x: number, y: number): number { return Math.min(x * y * (1 / 255), 255.0) } u8[vtx + 0] = modulate(u8[vtx + 0], additiveRGBA[v + 0]) u8[vtx + 1] = modulate(u8[vtx + 1], additiveRGBA[v + 1]) u8[vtx + 2] = modulate(u8[vtx + 2], additiveRGBA[v + 2]) u8[vtx + 0] = lerp(u8[vtx + 0], additiveRGBA[v + 0], opacity) u8[vtx + 1] = lerp(u8[vtx + 1], additiveRGBA[v + 1], opacity) u8[vtx + 2] = lerp(u8[vtx + 2], additiveRGBA[v + 2], opacity) } } // isAdditiveBlend // generate webGL buffers and vao gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer) gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, Uint32Array.from(tris), gl.STATIC_DRAW) gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer) // issue1129 // gl.bufferData(gl.ARRAY_BUFFER, Float32Array.from(posNormClr), gl.STATIC_DRAW) gl.bufferData(gl.ARRAY_BUFFER, u8, gl.STATIC_DRAW) this.indexCount = tris.length this.vertexCount = this.pts.length } // updateMesh() // internal function filters mesh to identify which color of triangulated mesh vertices reverseFaces(gl: WebGL2RenderingContext): void { if (this.offsetPt0) { return } // fiber not mesh if (this.hasConnectome) { return } // connectome not mesh const tris = this.tris || [] // TODO tris should probably be assigned in the constructor for (let j = 0; j < tris.length; j += 3) { const tri = tris[j] tris[j] = tris[j + 1] tris[j + 1] = tri } this.updateMesh(gl) // apply the new properties... } hierarchicalOrder(): number { const V0 = 12 const F0 = 20 const nF = this.tris.length / 3 const order = Math.log(nF / F0) / Math.log(4) // Sanity checks if (nF !== Math.pow(4, order) * F0) { return NaN } const nV = this.pts.length / 3 if (nV !== Math.pow(4, order) * (V0 - 2) + 2) { return NaN } // next checks are in case FreeSurfer was optimized with more local face indices // for an example see BrainMesh_ICBM152.lh.mz3 for (let i = 0; i < 15; i += 3) { if (this.tris[i] !== 0) { return NaN } } for (let i = 15; i < 24; i += 3) { if (this.tris[i] !== 3) { return NaN } } for (let i = 24; i < 30; i += 3) { if (this.tris[i] !== 4) { return NaN } } return order } decimateFaces(n: number, ntarget: number): void { let fac = this.tris // Constants for the icosahedron const V0 = 12 const F0 = 20 for (let j = n - 1; j >= ntarget; j--) { const nVjprev = Math.pow(4, j + 1) * (V0 - 2) + 2 const nVj = Math.pow(4, j) * (V0 - 2) + 2 const nFjprev = fac.length / 3 // = 4^(j+1)*F0 const nFj = Math.pow(4, j) * F0 log.info(`order ${j + 1} -> ${j} vertices ${nVjprev} -> ${nVj} faces ${nFjprev} -> ${nFj}`) const remap = Array.from({ length: nVjprev }, (_, i) => i + 1) for (let i = 0; i < nFjprev; i++) { const v1 = fac[3 * i] const v2 = fac[3 * i + 1] const v3 = fac[3 * i + 2] remap[v1 - 1] = Math.min(remap[v1 - 1], v2, v3) } const facJ = new Uint32Array(nFj * 3) for (let i = 0; i < nFj; i++) { facJ[3 * i] = remap[fac[3 * i] - 1] facJ[3 * i + 1] = remap[fac[3 * i + 1] - 1] facJ[3 * i + 2] = remap[fac[3 * i + 2] - 1] } fac = facJ } this.tris = new Uint32Array(fac) } // internal function simplifies FreeSurfer triangulated mesh and overlays decimateHierarchicalMesh(gl: WebGL2RenderingContext, order: number = 4): boolean { const inputOrder = this.hierarchicalOrder() if (isNaN(inputOrder)) { log.warn('Unable to decimate mesh: it does not have a hierarchical structure') return false } if (order >= inputOrder) { log.warn(`Unable to decimate mesh: input order (${inputOrder}) must be larger than downsampled order (${order})`) return false } const inputVLength = this.pts.length / 3 const V0 = 12 const nV = Math.pow(4, order) * (V0 - 2) + 2 this.pts = new Float32Array(this.pts.slice(0, nV * 3)) this.decimateFaces(inputOrder, order) if (this.layers && this.layers.length > 0) { for (let i = 0; i < this.layers.length; i++) { const layer = this.layers[i] if (layer.values instanceof Float32Array || layer.values.length !== inputVLength) { layer.values = new Float32Array(layer.values.slice(0, nV)) } else { log.warn(`decimation logic needs to be updated`) } } } this.updateMesh(gl) // apply the new properties... return true } // adjust attributes of a mesh layer. invoked by niivue.setMeshLayerProperty() // TODO this method is a bit too generic async setLayerProperty(id: number, key: keyof NVMeshLayer, val: number | string | boolean, gl: WebGL2RenderingContext): Promise { const layer = this.layers[id] if (!layer || !(key in layer)) { log.warn('mesh does not have property ', key, ' for layer ', layer) return } if (key === 'colormapLabel') { if (typeof val === 'object') { // assume JSON layer[key] = cmapper.makeLabelLut(val, 255, layer.global_max) } else if (typeof val === 'string') { // assume URL const cmap = await cmapper.makeLabelLutFromUrl(val) layer[key] = cmap this.updateMesh(gl) // apply the new properties... return } else { log.error('colormapLabel requires a string or object') } } else { // @ts-expect-error TODO generic property access layer[key] = val } this.updateMesh(gl) // apply the new properties... } // adjust mesh attributes. invoked by niivue.setMeshProperty(() // TODO this method is too generic setProperty(key: keyof this, val: number | string | boolean | Uint8Array | number[] | ColorMap | LegacyConnectome | Float32Array, gl: WebGL2RenderingContext): void { if (!(key in this)) { console.warn('Mesh does not have property:', key, this) return } /* if (typeof val !== 'number' && typeof val !== 'string' && typeof val !== 'boolean' && !Array.isArray(val)) { console.warn('Invalid value type. Expected number, numbers, string, or boolean but received:', typeof val) return } */ ;(this as any)[key] = val // TypeScript safety workaround this.updateMesh(gl) // Apply the new properties } // Each streamline vertex has color, normal and position attributes // Interleaved Vertex Data https://developer.apple.com/library/archive/documentation/3DDrawing/Conceptual/OpenGLES_ProgrammingGuide/TechniquesforWorkingwithVertexData/TechniquesforWorkingwithVertexData.html generatePosNormClr(pts: Float32Array, tris: Uint32Array, rgba255: Uint8Array): Float32Array { if (pts.length < 3 || rgba255.length < 4) { log.error('Catastrophic failure generatePosNormClr()') log.debug('this', this) log.debug('pts', pts) log.debug('rgba', rgba255) } const norms = NVMeshUtilities.generateNormals(pts, tris) const npt = pts.length / 3 const isPerVertexColors = npt === rgba255.length / 4 // n32 const f32PerVertex = this.f32PerVertex // 7 if NormalXYZ is 3 floats, 5 if normalXYZ is packed into rgb32 const f32 = new Float32Array(npt * f32PerVertex) // Each vertex has 7 components: PositionXYZ, NormalXYZ, RGBA32 const u8 = new Uint8Array(f32.buffer) // Each vertex has 7 components: PositionXYZ, NormalXYZ, RGBA32 let p = 0 // input position let c = 0 // input color let f = 0 // output float32 location (position and normals) let u = (f32PerVertex - 1) * 4 // output uint8 location (colors), offset 24 as after 3*position+3*normal for (let i = 0; i < npt; i++) { f32[f + 0] = pts[p + 0] f32[f + 1] = pts[p + 1] f32[f + 2] = pts[p + 2] if (f32PerVertex !== 7) { u8[u - 4] = norms[p + 0] * 127 u8[u - 3] = norms[p + 1] * 127 u8[u - 2] = norms[p + 2] * 127 } else { f32[f + 3] = norms[p + 0] f32[f + 4] = norms[p + 1] f32[f + 5] = norms[p + 2] } u8[u] = rgba255[c + 0] u8[u + 1] = rgba255[c + 1] u8[u + 2] = rgba255[c + 2] u8[u + 3] = rgba255[c + 3] if (isPerVertexColors) { c += 4 } p += 3 // read 3 input components: XYZ f += f32PerVertex // write 7 output components: 3*Position, 3*Normal, 1*RGBA u += f32PerVertex * 4 // stride of 28 bytes } return f32 } // wrapper to read meshes, tractograms and connectomes regardless of format static async readMesh(buffer: ArrayBuffer, name: string, gl: WebGL2RenderingContext, opacity = 1.0, rgba255 = new Uint8Array([255, 255, 255, 255]), visible = true): Promise { let tris: Uint32Array = new Uint32Array([]) let pts: Float32Array = new Float32Array([]) let anatomicalStructurePrimary = '' let obj: TCK | TRACT | TT | TRX | TRK | GII | MZ3 | X3D | VTK | DefaultMeshType const re = /(?:\.([^.]+))?$/ let ext = re.exec(name)![1] ext = ext.toUpperCase() if (ext === 'GZ') { ext = re.exec(name.slice(0, -3))![1] // img.trk.gz -> img.trk ext = ext.toUpperCase() } if (ext === 'JCON') { // return NVMesh.loadConnectomeFromJSON(JSON.parse(new TextDecoder().decode(buffer)), gl, name, opacity) log.error('you should never see this message: load using nvconnectome not nvmesh') } if (ext === 'JSON') { // return NVMesh.loadConnectomeFromFreeSurfer(JSON.parse(new TextDecoder().decode(buffer)), gl, name, opacity) log.error('you should never see this message: load using nvconnectome not nvmesh') } rgba255[3] = Math.max(1, rgba255[3]) if (ext === 'TCK' || ext === 'TRK' || ext === 'TT' || ext === 'TRX' || ext === 'TRACT') { if (ext === 'TCK') { obj = NVMeshLoaders.readTCK(buffer) } else if (ext === 'TRACT') { obj = NVMeshLoaders.readTRACT(buffer) } else if (ext === 'TT') { obj = await NVMeshLoaders.readTT(buffer) } else if (ext === 'TRX') { obj = await NVMeshLoaders.readTRX(buffer) } else { obj = await NVMeshLoaders.readTRK(buffer) } if (typeof obj === 'undefined') { const pts = new Float32Array([0, 0, 0, 0, 0, 0]) const offsetPt0 = new Uint32Array([0]) obj = { pts, offsetPt0 } log.error('Creating empty tracts') } rgba255[3] = 0.0 return new NVMesh( obj.pts, obj.offsetPt0, name, rgba255, // colormap, opacity, // opacity, visible, // visible, gl, 'inferno', (obj as TRX).dpg || null, (obj as TRX).dps || null, (obj as TRX).dpv || null, (obj as TRX).groups ) } // is fibers if (ext === 'GII') { obj = await NVMeshLoaders.readGII(buffer) } else if (ext === 'MZ3') { obj = await NVMeshLoaders.readMZ3(buffer) if (!('positions' in obj)) { log.warn('MZ3 does not have positions (statistical overlay?)') } } else if (ext === 'ASC') { obj = NVMeshLoaders.readASC(buffer) } else if (ext === 'DFS') { obj = NVMeshLoaders.readDFS(buffer) } else if (ext === 'BYU' || ext === 'G') { obj = NVMeshLoaders.readGEO(buffer) } else if (ext === 'GEO') { obj = NVMeshLoaders.readGEO(buffer, true) } else if (ext === 'ICO' || ext === 'TRI') { obj = NVMeshLoaders.readICO(buffer) } else if (ext === 'OFF') { obj = NVMeshLoaders.readOFF(buffer) } else if (ext === 'NV') { obj = NVMeshLoaders.readNV(buffer) } else if (ext === 'OBJ') { obj = await NVMeshLoaders.readOBJ(buffer) } else if (ext === 'PLY') { obj = NVMeshLoaders.readPLY(buffer) } else if (ext === 'WRL') { obj = NVMeshLoaders.readWRL(buffer) } else if (ext === 'X3D') { obj = NVMeshLoaders.readX3D(buffer) } else if (ext === 'FIB' || ext === 'VTK') { obj = NVMeshLoaders.readVTK(buffer) if ('offsetPt0' in obj) { // VTK files used both for meshes and streamlines rgba255[3] = 0.0 return new NVMesh( obj.pts, obj.offsetPt0, name, rgba255, // colormap, opacity, // opacity, visible, // visible, gl, 'inferno' ) } // if streamlines, not mesh } else if (ext === 'SRF') { obj = await NVMeshLoaders.readSRF(buffer) } else if (ext === 'STL') { obj = NVMeshLoaders.readSTL(buffer) } else { obj = NVMeshLoaders.readFreeSurfer(buffer) } // freesurfer hail mary if ((obj as GII).anatomicalStructurePrimary) { anatomicalStructurePrimary = (obj as GII).anatomicalStructurePrimary } if (obj instanceof Float32Array) { throw new Error('fatal: unknown mesh type loaded') } if (!('positions' in obj)) { throw new Error('positions not loaded') } if (!obj.indices) { throw new Error('indices not loaded') } pts = obj.positions tris = obj.indices if ('rgba255' in obj && obj.rgba255.length > 0) { // e.g. x3D format // rgba255 = Array.from(obj.rgba255) rgba255 = obj.rgba255 } if ('colors' in obj && obj.colors && obj.colors.length === pts.length) { const n = pts.length / 3 rgba255 = new Uint8Array(n * 4) let c = 0 let k = 0 for (let i = 0; i < n; i++) { // convert ThreeJS unit RGB to RGBA255 rgba255[k++] = obj.colors[c] * 255 // red rgba255[k++] = obj.colors[c + 1] * 255 // green rgba255[k++] = obj.colors[c + 2] * 255 // blue rgba255[k++] = 255 // alpha c += 3 } // for i: each vertex } // obj includes colors const npt = pts.length / 3 const ntri = tris.length / 3 if (ntri < 1 || npt < 3) { throw new Error('Mesh should have at least one triangle and three vertices') } rgba255[3] = Math.max(1, rgba255[3]) // mesh not streamline const nvm = new NVMesh( pts, tris, name, rgba255, // colormap, opacity, // opacity, visible, // visible, gl, null, // connectome null, // dpg null, // dps null, // dpv null, // groups true, // colorbarVisible anatomicalStructurePrimary ) if ('scalars' in obj && obj.scalars.length > 0) { const newLayer = await NVMeshLoaders.readLayer(name, buffer, nvm, opacity, 'gray') if (typeof newLayer === 'undefined') { log.warn('readLayer() failed to convert scalars') } else { nvm.layers.push(newLayer) nvm.updateMesh(gl) } } return nvm } static async loadLayer(layer: NVMeshLayer, nvmesh: NVMesh): Promise { let buffer = new Uint8Array().buffer function base64ToArrayBuffer(base64: string): ArrayBuffer { const binary_string = window.atob(base64) const len = binary_string.length const bytes = new Uint8Array(len) for (let i = 0; i < len; i++) { bytes[i] = binary_string.charCodeAt(i) } return bytes.buffer } if (layer.base64 !== undefined) { // populate buffer with base64 if exists buffer = base64ToArrayBuffer(layer.base64) } else { if (!layer.url) { throw new Error('layer: missing url') } // fetch url otherwise const response = await fetch(layer.url, { headers: layer.headers }) if (!response.ok) { throw Error(response.statusText) } buffer = await response.arrayBuffer() } let layerName: string let urlParts: string[] = [] if (layer.name && layer.name !== '') { layerName = layer.name } else { if (!layer.url) { throw new Error('layer: missing url') } // urlParts = layer.url.split("/"); // layerName = urlParts.slice(-1)[0]; try { // if a full url like https://domain/path/file.nii.gz?query=filter // parse the url and get the pathname component without the query urlParts = new URL(layer.url).pathname.split('/') } catch (e) { // if a relative url then parse the path (assuming no query) urlParts = layer.url.split('/') } finally { layerName = urlParts.slice(-1)[0] } } if (layerName.indexOf('?') > -1) { layerName = layerName.slice(0, layerName.indexOf('?')) // remove query string if any } let opacity = 0.5 if ('opacity' in layer) { opacity = layer.opacity } let colormap = 'warm' if ('colormap' in layer) { colormap = layer.colormap! } let colormapNegative = 'winter' if ('colormapNegative' in layer) { colormapNegative = layer.colormapNegative! } let useNegativeCmap = false if ('useNegativeCmap' in layer) { useNegativeCmap = layer.useNegativeCmap! } let cal_min: number | null = null if ('cal_min' in layer) { cal_min = layer.cal_min } let cal_max: number | null = null if ('cal_max' in layer) { cal_max = layer.cal_max } const newLayer = await NVMeshLoaders.readLayer(layerName, buffer, nvmesh, opacity, colormap, colormapNegative, useNegativeCmap, cal_min, cal_max) if (newLayer) { nvmesh.layers.push(newLayer) } } /** * factory function to load and return a new NVMesh instance from a given URL */ static async loadFromUrl({ url = '', headers = {}, gl, name = '', opacity = 1.0, rgba255 = [255, 255, 255, 255], visible = true, layers = [], buffer = new ArrayBuffer(0), meshShaderIndex = 0 }: Partial = {}): Promise { let urlParts = url.split('/') // split url parts at slash if (name === '') { try { // if a full url like https://domain/path/file.nii.gz?query=filter // parse the url and get the pathname component without the query urlParts = new URL(url).pathname.split('/') } catch (e) { // if a relative url then parse the path (assuming no query) urlParts = url.split('/') } name = urlParts.slice(-1)[0] // name will be last part of url (e.g. some/url/image.nii.gz --> image.nii.gz if (name.indexOf('?') > -1) { name = name.slice(0, name.indexOf('?')) // remove query string if any } } if (url === '') { throw Error('url must not be empty') } if (!gl) { throw Error('gl context is null') } let buff if (buffer.byteLength > 0) { buff = buffer } else { // TRX format is special (its a zip archive of multiple files) const response = await fetch(url, { headers }) if (!response.ok) { throw Error(response.statusText) } buff = await response.arrayBuffer() } const nvmesh = await this.readMesh(buff, name, gl, opacity, new Uint8Array(rgba255), visible) // Set mesh shader index if provided if (meshShaderIndex !== undefined) { nvmesh.meshShaderIndex = meshShaderIndex } if (!layers || layers.length < 1) { return nvmesh } for (let i = 0; i < layers.length; i++) { await NVMesh.loadLayer(layers[i], nvmesh) } // apply the new properties nvmesh.updateMesh(gl) return nvmesh } // not included in public docs // loading Nifti files static async readFileAsync(file: Blob): Promise { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = (): void => { resolve(reader.result as ArrayBuffer) } reader.onerror = reject reader.readAsArrayBuffer(file) }) } /** * factory function to load and return a new NVMesh instance from a file in the browser * * @returns NVMesh instance */ static async loadFromFile({ file, gl, name = '', opacity = 1.0, rgba255 = [255, 255, 255, 255], visible = true, layers = [] }: Partial = {}): Promise { if (!file) { throw new Error('file must be set') } if (!gl) { throw new Error('rendering context must be set') } const buffer = await NVMesh.readFileAsync(file) const nvmesh = await NVMesh.readMesh(buffer, name, gl, opacity, new Uint8Array(rgba255), visible) if (!layers || layers.length < 1) { return nvmesh } for (let i = 0; i < layers.length; i++) { await NVMesh.loadLayer(layers[i], nvmesh) } // apply the new properties nvmesh.updateMesh(gl) return nvmesh } /** * load and return a new NVMesh instance from a base64 encoded string */ async loadFromBase64({ base64, gl, name = '', opacity = 1.0, rgba255 = [255, 255, 255, 255], visible = true, layers = [] }: Partial = {}): Promise { if (!base64) { throw new Error('base64 must bet set') } if (!gl) { throw new Error('rendering context must be set') } // https://stackoverflow.com/questions/21797299/convert-base64-string-to-arraybuffer function base64ToArrayBuffer(base64: string): ArrayBuffer { const binary_string = window.atob(base64) const len = binary_string.length const bytes = new Uint8Array(len) for (let i = 0; i < len; i++) { bytes[i] = binary_string.charCodeAt(i) } return bytes.buffer } const buffer = base64ToArrayBuffer(base64) const nvmesh = await NVMesh.readMesh(buffer, name, gl, opacity, new Uint8Array(rgba255), visible) if (!layers || layers.length < 1) { return nvmesh } for (let i = 0; i < layers.length; i++) { await NVMesh.loadLayer(layers[i], nvmesh) } // apply new properties nvmesh.updateMesh(gl) return nvmesh } }