import { mat4, vec4, vec3 } from 'gl-matrix' import { log } from '@/logger' import { NVUtilities, Zip } from '@/nvutilities' import { ColorMap, LUT, cmapper } from '@/colortables' import { NiivueObject3D } from '@/niivue-object3D' import { NVMesh, NVMeshLayer, NVMeshLayerDefaults } from '@/nvmesh' import { ANNOT, DefaultMeshType, GII, // Layer, MGH, MZ3, SmpMap, TCK, TRACT, TRK, TT, TRX, VTK, X3D, XmlTag, AnyNumberArray, ValuesArray } from '@/nvmesh-types' const utiltiesLogger = log /** * Class to load different mesh formats * @ignore */ export class NVMeshLoaders { // read undocumented AFNI tract.niml format streamlines static readTRACT(buffer: ArrayBuffer): TRACT { const len = buffer.byteLength if (len < 20) { throw new Error('File too small to be niml.tract: bytes = ' + len) } const reader = new DataView(buffer) const bytes = new Uint8Array(buffer) let pos = 0 function readStr(): string { // read until right angle bracket ">" while (pos < len && bytes[pos] !== 60) { pos++ } // start with "<" const startPos = pos while (pos < len && bytes[pos] !== 62) { pos++ } pos++ // skip EOLN if (pos - startPos < 1) { return '' } return new TextDecoder().decode(buffer.slice(startPos, pos - 1)).trim() } let line = readStr() // 1st line: signature ' } const dps = [] dps.push({ id: 'tract', vals: Float32Array.from(bundleTags) }) return { pts: new Float32Array(pts), offsetPt0: new Uint32Array(offsetPt0), dps } } // readTRACT() // https://dsi-studio.labsolver.org/doc/cli_data.html // https://brain.labsolver.org/hcp_trk_atlas.html static async readTT(buffer: ArrayBuffer): Promise { // Read a Matlab V4 file, n.b. does not support modern versions // https://www.mathworks.com/help/pdf_doc/matlab/matfile_format.pdf let offsetPt0 = new Uint32Array(0) let pts = new Float32Array(0) const mat = await NVUtilities.readMatV4(buffer) if (!('trans_to_mni' in mat)) { throw new Error("TT format file must have 'trans_to_mni'") } if (!('voxel_size' in mat)) { throw new Error("TT format file must have 'voxel_size'") } if (!('track' in mat)) { throw new Error("TT format file must have 'track'") } let trans_to_mni = mat4.create() const m = mat.trans_to_mni trans_to_mni = mat4.fromValues(m[0], m[1], m[2], m[3], m[4], m[5], m[6], m[7], m[8], m[9], m[10], m[11], m[12], m[13], m[14], m[15]) mat4.transpose(trans_to_mni, trans_to_mni) // unlike TRK, TT uses voxel centers, not voxel corners function parse_tt(track: Float64Array | Float32Array | Uint32Array | Uint16Array | Uint8Array | Int32Array | Int16Array | Int8Array): void { const dv = new DataView(track.buffer) const pos = [] let nvert3 = 0 let i = 0 while (i < track.length) { pos.push(i) const newpts = dv.getUint32(i, true) i = i + newpts + 13 nvert3 += newpts } offsetPt0 = new Uint32Array(pos.length + 1) pts = new Float32Array(nvert3) let npt = 0 for (let i = 0; i < pos.length; i++) { offsetPt0[i] = npt / 3 let p = pos[i] const sz = dv.getUint32(p, true) / 3 let x = dv.getInt32(p + 4, true) let y = dv.getInt32(p + 8, true) let z = dv.getInt32(p + 12, true) p += 16 pts[npt++] = x pts[npt++] = y pts[npt++] = z for (let j = 2; j <= sz; j++) { x = x + dv.getInt8(p++) y = y + dv.getInt8(p++) z = z + dv.getInt8(p++) pts[npt++] = x pts[npt++] = y pts[npt++] = z } } // for each streamline for (let i = 0; i < npt; i++) { pts[i] = pts[i] / 32.0 } let v = 0 for (let i = 0; i < npt / 3; i++) { const pos = vec4.fromValues(pts[v], pts[v + 1], pts[v + 2], 1) vec4.transformMat4(pos, pos, trans_to_mni) pts[v++] = pos[0] pts[v++] = pos[1] pts[v++] = pos[2] } offsetPt0[pos.length] = npt / 3 // solve fence post problem, offset for final streamline } // parse_tt() parse_tt(mat.track) return { pts, offsetPt0 } } // readTT /** * Assemble dpg from a map-of-groups into a ValuesArray ordered by groups[]. * Missing group data or tags are padded with NaN to maintain group alignment. * * @param dpgMap - map from groupId -> ValuesArray (entries for that group) * @param groups - ValuesArray describing groups; defines the result ordering * @returns ValuesArray - one entry per unique tag found across all groups * @throws Error if "groups" is empty or missing */ static assembleDpgFromMap(dpgMap: Record, groups: ValuesArray): ValuesArray { if (!Array.isArray(groups) || groups.length === 0) { throw new Error('assembleDpgFromMap: "groups" is empty or missing; cannot assemble dpg.') } // 1. Identify all unique tags across all existing groups in dpgMap const allTags = new Set() for (const gid in dpgMap) { dpgMap[gid].forEach((entry) => allTags.add(entry.id)) } const result: ValuesArray = [] // 2. For each unique tag found in the dpgMap... for (const tag of allTags) { const perGroupArrays: Float32Array[] = [] let totalLen = 0 // 3. Iterate through every group defined in the TRX header for (let gi = 0; gi < groups.length; gi++) { const gid = String(groups[gi].id) const entries = dpgMap[gid] || [] // Handle missing group in map const entry = entries.find((e) => e.id === tag) if (entry) { // Use actual data if it exists const vals = entry.vals instanceof Float32Array ? entry.vals : Float32Array.from(entry.vals as Iterable) perGroupArrays.push(vals) totalLen += vals.length } else { // If tag is missing for this group, fill with NaN (assuming scalar dpg) // Note: TRX dpg is typically 1 value per group. const fallback = new Float32Array([NaN]) perGroupArrays.push(fallback) totalLen += fallback.length } } // 4. Concatenate results for the tag const merged = new Float32Array(totalLen) let off = 0 for (const arr of perGroupArrays) { merged.set(arr, off) off += arr.length } result.push({ id: tag, vals: merged }) } return result } // read TRX format tractogram // https://github.com/tee-ar-ex/trx-spec/blob/master/specifications.md static async readTRX(buffer: ArrayBuffer): Promise { // Javascript does not support float16, so we convert to float32 // https://stackoverflow.com/questions/5678432/decompressing-half-precision-floats-in-javascript function decodeFloat16(binary: number): number { 'use strict' const exponent = (binary & 0x7c00) >> 10 const fraction = binary & 0x03ff return (binary >> 15 ? -1 : 1) * (exponent ? (exponent === 0x1f ? (fraction ? NaN : Infinity) : Math.pow(2, exponent - 15) * (1 + fraction / 0x400)) : 6.103515625e-5 * (fraction / 0x400)) } // decodeFloat16() let noff = 0 let npt = 0 let pts = new Float32Array([]) const offsetPt0: number[] = [] const dpgMap: Record = {} const groups = [] const dps = [] const dpv = [] let header = [] let isOverflowUint64 = false const zip = new Zip(buffer) for (let i = 0; i < zip.entries.length; i++) { const entry = zip.entries[i] if (entry.uncompressedSize === 0) { continue // e.g. folder } const parts = entry.fileName.split('/') const fname = parts.slice(-1)[0] // my.trx/dpv/fx.float32 -> fx.float32 if (fname.startsWith('.')) { continue } const dname = parts.slice(-3)[0] // my.trx/dpg/ARC_L/z.float32 -> dpg const pname = parts.slice(-2)[0] // my.trx/dpv/fx.float32 -> dpv const tag = fname.split('.')[0] // "positions.3.float16 -> "positions" const data = await entry.extract() // const data = await NVUtilities.zipInflate(buffer, entry.startsAt, entry.compressedSize, entry.uncompressedSize, entry.compressionMethod ) // console.log(`entry ${pname} ${fname} ${tag} : ${data.length}`) if (fname.includes('header.json')) { const jsonString = new TextDecoder().decode(data) header = JSON.parse(jsonString) continue } // next read arrays for all possible datatypes: int8/16/32/64 uint8/16/32/64 float16/32/64 let nval = 0 let vals: AnyNumberArray = [] if (fname.endsWith('.uint64') || fname.endsWith('.int64')) { // javascript does not have 64-bit integers! read lower 32-bits // note for signed int64 we only read unsigned bytes // for both signed and unsigned, generate an error if any value is out of bounds // one alternative might be to convert to 64-bit double that has a flintmax of 2^53. nval = data.length / 8 // 8 bytes per 64bit input vals = new Uint32Array(nval) const u32 = new Uint32Array(data.buffer) let j = 0 for (let i = 0; i < nval; i++) { vals[i] = u32[j] if (u32[j + 1] !== 0) { isOverflowUint64 = true } j += 2 } } else if (fname.endsWith('.uint32')) { vals = new Uint32Array(data.buffer) } else if (fname.endsWith('.uint16')) { vals = new Uint16Array(data.buffer) } else if (fname.endsWith('.uint8')) { vals = new Uint8Array(data.buffer) } else if (fname.endsWith('.int32')) { vals = new Int32Array(data.buffer) } else if (fname.endsWith('.int16')) { vals = new Int16Array(data.buffer) } else if (fname.endsWith('.int8')) { vals = new Int8Array(data.buffer) } else if (fname.endsWith('.float64')) { vals = new Float64Array(data.buffer) } else if (fname.endsWith('.float32')) { vals = new Float32Array(data.buffer) } else if (fname.endsWith('.float16')) { // javascript does not have 16-bit floats! Convert to 32-bits nval = data.length / 2 // 2 bytes per 16bit input vals = new Float32Array(nval) const u16 = new Uint16Array(data.buffer) const lut = new Float32Array(65536) for (let i = 0; i < 65536; i++) { lut[i] = decodeFloat16(i) } for (let i = 0; i < nval; i++) { vals[i] = lut[u16[i]] } } else { continue } // not a data array nval = vals.length // next: read data_per_group if (pname.includes('groups')) { groups.push({ id: tag, vals: Float32Array.from(vals.slice()) }) continue } if (dname.includes('dpg')) { const key = String(pname) // be defensive; ensure key is a string if (!dpgMap[key]) { dpgMap[key] = [] } dpgMap[key].push({ id: tag, vals: Float32Array.from(vals.slice()) }) continue } // next: read data_per_vertex if (pname.includes('dpv')) { dpv.push({ id: tag, vals: Float32Array.from(vals.slice()) }) continue } // next: read data_per_streamline if (pname.includes('dps')) { dps.push({ id: tag, vals: Float32Array.from(vals.slice()) }) continue } // Next: read offsets: Always uint64 if (fname.startsWith('offsets.')) { // javascript does not have 64-bit integers! read lower 32-bits noff = nval // 8 bytes per 64bit input // we need to solve the fence post problem, so we can not use slice for (let i = 0; i < nval; i++) { offsetPt0[i] = vals[i] } } if (fname.startsWith('positions.3.')) { npt = nval // 4 bytes per 32bit input pts = new Float32Array(vals) } } if (noff === 0 || npt === 0) { throw new Error('Failure reading TRX format (no offsets or points).') } if (isOverflowUint64) { // TODO use BigInt throw new Error('Too many vertices: JavaScript does not support 64 bit integers') } let dpg = [] if (groups.length > 0 && dpgMap && Object.keys(dpgMap).length > 0) { dpg = this.assembleDpgFromMap(dpgMap, groups) } offsetPt0[noff] = npt / 3 // solve fence post problem, offset for final streamline return { pts, offsetPt0: new Uint32Array(offsetPt0), dpg, dps, dpv, groups, header } } // readTRX() // issue1426 MRtrix data per streamline as ASCII text static readTXT(buffer: ArrayBuffer, n_count = 0): Float32Array { // Decode ASCII (or UTF-8) bytes into a string const text = new TextDecoder('utf-8').decode(buffer) // Split into lines, handling any line endings: \r\n, \r, or \n const lines = text.split(/\r?\n|\r/).filter((l) => l.trim().length > 0) // If n_count not specified, use number of lines if (n_count <= 0) { n_count = lines.length } const vals = new Float32Array(n_count) // Parse each line into a float for (let i = 0; i < n_count && i < lines.length; i++) { const v = parseFloat(lines[i].trim()) vals[i] = Number.isFinite(v) ? v : 0.0 } return vals } // read mrtrix tsf format Track Scalar Files - these are are DPV // https://mrtrix.readthedocs.io/en/dev/getting_started/image_data.html#track-scalar-file-format-tsf static readTSF(buffer: ArrayBuffer, n_vert = 0): Float32Array { const vals = new Float32Array(n_vert) const len = buffer.byteLength if (len < 20) { throw new Error('File too small to be TSF: bytes = ' + len) } const bytes = new Uint8Array(buffer) let pos = 0 function readStr(): string { while (pos < len && bytes[pos] === 10) { pos++ } // skip blank lines const startPos = pos while (pos < len && bytes[pos] !== 10) { pos++ } pos++ // skip EOLN if (pos - startPos < 1) { return '' } return new TextDecoder().decode(buffer.slice(startPos, pos - 1)) } let line = readStr() // 1st line: signature 'mrtrix tracks' if (!line.includes('mrtrix track scalars')) { throw new Error('Not a valid TSF file') } let offset = -1 // "file: offset" is REQUIRED while (pos < len && !line.includes('END')) { line = readStr() if (line.toLowerCase().startsWith('file:')) { offset = parseInt(line.split(' ').pop()!) } if (line.toLowerCase().startsWith('datatype:') && !line.endsWith('Float32LE')) { throw new Error('Only supports TSF files with Float32LE') } } if (offset < 20) { throw new Error('Not a valid TSF file (missing file offset)') } pos = offset const reader = new DataView(buffer) // read and transform vertex positions let npt = 0 while (pos + 4 <= len && npt < n_vert) { const ptx = reader.getFloat32(pos, true) pos += 4 if (!isFinite(ptx)) { // both NaN and Infinity are not finite if (!isNaN(ptx)) { // terminate if infinity break } } else { vals[npt++] = ptx } } return vals } // readTSF // read mrtrix tck format streamlines // https://mrtrix.readthedocs.io/en/latest/getting_started/image_data.html#tracks-file-format-tck static readTCK(buffer: ArrayBuffer): TCK { const len = buffer.byteLength if (len < 20) { throw new Error('File too small to be TCK: bytes = ' + len) } const bytes = new Uint8Array(buffer) let pos = 0 function readStr(): string { while (pos < len && bytes[pos] === 10) { pos++ } // skip blank lines const startPos = pos while (pos < len && bytes[pos] !== 10) { pos++ } pos++ // skip EOLN if (pos - startPos < 1) { return '' } return new TextDecoder().decode(buffer.slice(startPos, pos - 1)) } let line = readStr() // 1st line: signature 'mrtrix tracks' if (!line.includes('mrtrix tracks')) { throw new Error('Not a valid TCK file') } let offset = -1 // "file: offset" is REQUIRED while (pos < len && !line.includes('END')) { line = readStr() if (line.toLowerCase().startsWith('file:')) { offset = parseInt(line.split(' ').pop()!) } } if (offset < 20) { throw new Error('Not a valid TCK file (missing file offset)') } pos = offset const reader = new DataView(buffer) // read and transform vertex positions let npt = 0 // over-provision offset array to store number of segments let offsetPt0 = new Uint32Array(len / (4 * 4)) let noffset = 0 // over-provision points array to store vertex positions let npt3 = 0 let pts = new Float32Array(len / 4) offsetPt0[0] = 0 // 1st streamline starts at 0 while (pos + 12 < len) { const ptx = reader.getFloat32(pos, true) pos += 4 const pty = reader.getFloat32(pos, true) pos += 4 const ptz = reader.getFloat32(pos, true) pos += 4 if (!isFinite(ptx)) { // both NaN and Infinity are not finite offsetPt0[noffset++] = npt if (!isNaN(ptx)) { // terminate if infinity break } } else { pts[npt3++] = ptx pts[npt3++] = pty pts[npt3++] = ptz npt++ } } // resize offset/vertex arrays that were initially over-provisioned pts = pts.slice(0, npt3) offsetPt0 = offsetPt0.slice(0, noffset) return { pts, offsetPt0 } } // readTCK() // not included in public docs // read trackvis trk format streamlines // http://trackvis.org/docs/?subsect=fileformat static async readTRK(buffer: ArrayBuffer): Promise { // https://brain.labsolver.org/hcp_trk_atlas.html // https://github.com/xtk/X/tree/master/io // in practice, always little endian let reader = new DataView(buffer) let magic = reader.getUint32(0, true) // 'TRAC' if (magic !== 1128354388) { // e.g. TRK.gz let raw if (magic === 4247762216) { // e.g. TRK.zstd // raw = fzstd.decompress(new Uint8Array(buffer)); // raw = new Uint8Array(raw); throw new Error('zstd TRK decompression is not supported') } else { raw = await NVUtilities.decompress(new Uint8Array(buffer)) } buffer = raw.buffer reader = new DataView(buffer) magic = reader.getUint32(0, true) // 'TRAC' } const vers = reader.getUint32(992, true) // 2 const hdr_sz = reader.getUint32(996, true) // 1000 if (vers > 2 || hdr_sz !== 1000 || magic !== 1128354388) { throw new Error('Not a valid TRK file') } const n_scalars = reader.getInt16(36, true) const dpv = [] // data_per_vertex for (let i = 0; i < n_scalars; i++) { const arr = new Uint8Array(buffer.slice(38 + i * 20, 58 + i * 20)) const str = new TextDecoder().decode(arr).split('\0').shift() dpv.push({ id: str!.trim(), // TODO can we guarantee this? vals: [] as number[] }) } const voxel_sizeX = reader.getFloat32(12, true) const voxel_sizeY = reader.getFloat32(16, true) const voxel_sizeZ = reader.getFloat32(20, true) const zoomMat = mat4.fromValues(1 / voxel_sizeX, 0, 0, -0.5, 0, 1 / voxel_sizeY, 0, -0.5, 0, 0, 1 / voxel_sizeZ, -0.5, 0, 0, 0, 1) const n_properties = reader.getInt16(238, true) const dps = [] // data_per_streamline for (let i = 0; i < n_properties; i++) { const arr = new Uint8Array(buffer.slice(240 + i * 20, 260 + i * 20)) const str = new TextDecoder().decode(arr).split('\0').shift() dps.push({ id: str!.trim(), // TODO can we guarantee this? vals: [] as number[] }) } const mat = mat4.create() for (let i = 0; i < 16; i++) { mat[i] = reader.getFloat32(440 + i * 4, true) } if (mat[15] === 0.0) { // vox_to_ras[3][3] is 0, it means the matrix is not recorded log.warn('TRK vox_to_ras not set') mat4.identity(mat) } const vox2mmMat = mat4.create() mat4.mul(vox2mmMat, zoomMat, mat) let i32 = null let f32 = null i32 = new Int32Array(buffer.slice(hdr_sz)) f32 = new Float32Array(i32.buffer) const ntracks = i32.length if (ntracks < 1) { throw new Error('Empty TRK file.') } // read and transform vertex positions let i = 0 let npt = 0 // pre-allocate and over-provision offset array let offsetPt0 = new Uint32Array(i32.length / 4) let noffset = 0 // pre-allocate and over-provision vertex positions array let pts = new Float32Array(i32.length) let npt3 = 0 while (i < ntracks) { const n_pts = i32[i] i = i + 1 // read 1 32-bit integer for number of points in this streamline offsetPt0[noffset++] = npt for (let j = 0; j < n_pts; j++) { const ptx = f32[i + 0] const pty = f32[i + 1] const ptz = f32[i + 2] i += 3 // read 3 32-bit floats for XYZ position pts[npt3++] = ptx * vox2mmMat[0] + pty * vox2mmMat[1] + ptz * vox2mmMat[2] + vox2mmMat[3] pts[npt3++] = ptx * vox2mmMat[4] + pty * vox2mmMat[5] + ptz * vox2mmMat[6] + vox2mmMat[7] pts[npt3++] = ptx * vox2mmMat[8] + pty * vox2mmMat[9] + ptz * vox2mmMat[10] + vox2mmMat[11] if (n_scalars > 0) { for (let s = 0; s < n_scalars; s++) { dpv[s].vals.push(f32[i]) i++ } } npt++ } // for j: each point in streamline if (n_properties > 0) { for (let j = 0; j < n_properties; j++) { dps[j].vals.push(f32[i]) i++ } } } // for each streamline: while i < n_count // output uses static float32 not dynamic number[] const dps32 = [] // data_per_streamline for (let i = 0; i < dps.length; i++) { dps32.push({ id: dps[i].id, vals: Float32Array.from(dps[i].vals) }) } const dpv32 = [] for (let i = 0; i < dpv.length; i++) { dpv32.push({ id: dpv[i].id, vals: Float32Array.from(dpv[i].vals) }) } // add 'first index' as if one more line was added (fence post problem) offsetPt0[noffset++] = npt // resize offset/vertex arrays that were initially over-provisioned pts = pts.slice(0, npt3) offsetPt0 = offsetPt0.slice(0, noffset) return { pts, offsetPt0, dps: dps32, dpv: dpv32 } } // readTRK() // read legacy VTK text format file static readTxtVTK(buffer: ArrayBuffer): VTK { const enc = new TextDecoder('utf-8') const txt = enc.decode(buffer) const lines = txt.split('\n') const n = lines.length if (n < 7 || !lines[0].startsWith('# vtk DataFile')) { throw new Error('Invalid VTK image') } if (!lines[2].startsWith('ASCII')) { throw new Error('Not ASCII VTK mesh') } let pos = 3 while (lines[pos].length < 1) { pos++ } // skip blank lines if (!lines[pos].includes('POLYDATA')) { throw new Error('Not ASCII VTK polydata') } pos++ while (lines[pos].length < 1) { pos++ } // skip blank lines if (!lines[pos].startsWith('POINTS')) { throw new Error('Not VTK POINTS') } let items = lines[pos].trim().split(/\s+/) const nvert = parseInt(items[1]) // POINTS 10261 float const nvert3 = nvert * 3 const positions = new Float32Array(nvert * 3) let v = 0 while (v < nvert * 3) { pos++ const str = lines[pos].trim() const pts = str.trim().split(/\s+/) for (let i = 0; i < pts.length; i++) { if (v >= nvert3) { break } positions[v] = parseFloat(pts[i]) v++ } } const tris = [] pos++ while (lines[pos].length < 1) { pos++ } // skip blank lines if (lines[pos].startsWith('METADATA')) { while (lines[pos].length > 1) { pos++ } // skip until blank line pos++ } items = lines[pos].trim().split(/\s+/) pos++ if (items[0].includes('LINES')) { const n_count = parseInt(items[1]) if (n_count < 1) { throw new Error('Corrupted VTK ASCII') } let str = lines[pos].trim() const offsetPt0 = [] let pts: number[] = [] if (str.startsWith('OFFSETS')) { // 'new' line style https://discourse.vtk.org/t/upcoming-changes-to-vtkcellarray/2066 pos++ let c = 0 while (c < n_count) { str = lines[pos].trim() pos++ const items = str.trim().split(/\s+/) for (let i = 0; i < items.length; i++) { offsetPt0[c] = parseInt(items[i]) c++ if (c >= n_count) { break } } // for each line } // while offset array not filled pts = Array.from(positions) } else { // classic line style https://www.visitusers.org/index.php?title=ASCII_VTK_Files let npt = 0 offsetPt0[0] = 0 // 1st streamline starts at 0 let asciiInts: number[] = [] let asciiIntsPos = 0 function lineToInts(): void { // VTK can save one array across multiple ASCII lines str = lines[pos].trim() const items = str.trim().split(/\s+/) asciiInts = [] for (let i = 0; i < items.length; i++) { asciiInts.push(parseInt(items[i])) } asciiIntsPos = 0 pos++ } lineToInts() for (let c = 0; c < n_count; c++) { if (asciiIntsPos >= asciiInts.length) { lineToInts() } const numPoints = asciiInts[asciiIntsPos++] npt += numPoints offsetPt0[c + 1] = npt for (let i = 0; i < numPoints; i++) { if (asciiIntsPos >= asciiInts.length) { lineToInts() } const idx = asciiInts[asciiIntsPos++] * 3 pts.push(positions[idx + 0]) // X pts.push(positions[idx + 1]) // Y pts.push(positions[idx + 2]) // Z } // for numPoints: number of segments in streamline } // for n_count: number of streamlines } return { pts: Float32Array.from(pts), offsetPt0: Uint32Array.from(offsetPt0) } } else if (items[0].includes('TRIANGLE_STRIPS')) { const nstrip = parseInt(items[1]) for (let i = 0; i < nstrip; i++) { const str = lines[pos].trim() pos++ const vs = str.trim().split(/\s+/) const ntri = parseInt(vs[0]) - 2 // -2 as triangle strip is creates pts - 2 faces let k = 1 for (let t = 0; t < ntri; t++) { if (t % 2) { // preserve winding order tris.push(parseInt(vs[k + 2])) tris.push(parseInt(vs[k + 1])) tris.push(parseInt(vs[k])) } else { tris.push(parseInt(vs[k])) tris.push(parseInt(vs[k + 1])) tris.push(parseInt(vs[k + 2])) } k += 1 } // for each triangle } // for each strip } else if (items[0].includes('POLYGONS')) { const npoly = parseInt(items[1]) for (let i = 0; i < npoly; i++) { const str = lines[pos].trim() pos++ const vs = str.trim().split(/\s+/) const ntri = parseInt(vs[0]) - 2 // e.g. 3 for triangle const fx = parseInt(vs[1]) let fy = parseInt(vs[2]) for (let t = 0; t < ntri; t++) { const fz = parseInt(vs[3 + t]) tris.push(fx) tris.push(fy) tris.push(fz) fy = fz } } } else { throw new Error('Unsupported ASCII VTK datatype ' + items[0]) } const indices = new Uint32Array(tris) return { positions, indices } } // readTxtVTK() // read mesh overlay to influence vertex colors static async readLayer( name: string = '', buffer: ArrayBuffer, nvmesh: NVMesh, opacity = 0.5, colormap = 'warm', colormapNegative = 'winter', useNegativeCmap = false, cal_min: number | null = null, cal_max: number | null = null, outlineBorder = 0 ): Promise { const layer: NVMeshLayer = { ...NVMeshLayerDefaults, colormapInvert: false, colormapType: 0, // COLORMAP_TYPE.MIN_TO_MAX isTransparentBelowCalMin: true, isAdditiveBlend: false, colorbarVisible: true, colormapLabel: null } const isReadColortables = true const re = /(?:\.([^.]+))?$/ let ext = re.exec(name)![1] // TODO can we guarantee this? ext = ext.toUpperCase() if (ext === 'GZ') { ext = re.exec(name.slice(0, -3))![1] // img.trk.gz -> img.trk ext = ext.toUpperCase() } const n_vert = nvmesh.vertexCount / 3 // each vertex has XYZ component if (nvmesh.offsetPt0) { // for streamlines, we can only read .tsf and txt files // typescript hell commences for one liner // const tag = name.split('/')!.pop()!.split('.')!.slice(0, -1).join('.')! const splitResult = name.split('/') let tag = 'Unknown' if (splitResult.length > 1) { const tag1 = splitResult.pop() if (tag1) { tag = tag.split('.').slice(0, -1).join('.') } } if (ext === 'TXT') { const n_count = nvmesh.offsetPt0.length - 1 const vals = NVMeshLoaders.readTXT(buffer, n_count) if (vals.length !== n_count) { throw new Error(`TXT file has ${vals.length} items, expected one per streamline (${n_count}).`) } if (!nvmesh.dps) { nvmesh.dps = [] } const mn = vals.reduce((acc, current) => Math.min(acc, current)) const mx = vals.reduce((acc, current) => Math.max(acc, current)) nvmesh.dps.push({ id: tag, vals: Float32Array.from(vals.slice()), global_min: mn, global_max: mx, cal_min: mn, cal_max: mx }) return layer } if (ext === 'NII') { const vals = (await NVMeshLoaders.readNII(buffer, nvmesh.pts, '')) as Float32Array // const npt = nvmesh.pts.length / 3 const mn = vals.reduce((acc, current) => Math.min(acc, current)) const mx = vals.reduce((acc, current) => Math.max(acc, current)) nvmesh.dpv.push({ id: tag, vals: Float32Array.from(vals.slice()), global_min: mn, global_max: mx, cal_min: mn, cal_max: mx }) // console.log(`>>> ${tag} got ${vals.length} expected ${nvmesh.pts.length / 3}`) return layer } if (ext !== 'TSF') { throw new Error('readLayer for streamlines only supports TSF and TXT files.') } const npt = nvmesh.pts.length / 3 // return to readable javascript const vals = NVMeshLoaders.readTSF(buffer, npt) if (!nvmesh.dpv) { nvmesh.dpv = [] } const mn = vals.reduce((acc, current) => Math.min(acc, current)) const mx = vals.reduce((acc, current) => Math.max(acc, current)) nvmesh.dpv.push({ id: tag, vals: Float32Array.from(vals.slice()), global_min: mn, global_max: mx, cal_min: mn, cal_max: mx }) return layer } if (n_vert < 3) { log.error('n_vert < 3 in layer') return } if (ext === 'MZ3') { const obj = await NVMeshLoaders.readMZ3(buffer, n_vert) layer.values = obj.scalars if ('colormapLabel' in obj) { layer.colormapLabel = obj.colormapLabel } } else if (ext === 'ANNOT') { if (!isReadColortables) { // TODO: bogus ANNOT return type layer.values = NVMeshLoaders.readANNOT(buffer, n_vert) as unknown as Float32Array } else { const obj = NVMeshLoaders.readANNOT(buffer, n_vert, true) if (!(obj instanceof Uint32Array)) { layer.values = obj.scalars layer.colormapLabel = obj.colormapLabel } // unable to decode colormapLabel else { layer.values = obj } } } else if (ext === 'CRV' || ext === 'CURV' || ext === 'THICKNESS' || ext === 'AREA') { layer.values = NVMeshLoaders.readCURV(buffer, n_vert) layer.isTransparentBelowCalMin = false } else if (ext === 'GII') { const obj = await NVMeshLoaders.readGII(buffer, n_vert) layer.values = obj.scalars // colormapLabel layer.colormapLabel = obj.colormapLabel } else if (ext === 'MGH' || ext === 'MGZ') { if (!isReadColortables) { layer.values = (await NVMeshLoaders.readMGH(buffer, n_vert)) as number[] } else { const obj = await NVMeshLoaders.readMGH(buffer, n_vert, true) if ('scalars' in obj) { layer.values = obj.scalars layer.colormapLabel = obj.colormapLabel } // unable to decode colormapLabel else { layer.values = obj } } } else if (ext === 'NII') { layer.values = (await NVMeshLoaders.readNII(buffer, nvmesh.pts, nvmesh.anatomicalStructurePrimary)) as Float32Array } else if (ext === 'SMP') { layer.values = await NVMeshLoaders.readSMP(buffer, n_vert) } else if (ext === 'STC') { layer.values = NVMeshLoaders.readSTC(buffer, n_vert) } else if (NVMeshLoaders.isCurv(buffer)) { // Unknown layer overlay format - hail mary assume FreeSurfer layer.values = NVMeshLoaders.readCURV(buffer, n_vert) layer.isTransparentBelowCalMin = false } else { log.warn('Unknown layer overlay format ' + name) return layer } if (!layer.values) { log.error('no values in layer') return } layer.nFrame4D = layer.values.length / n_vert layer.frame4D = 0 layer.outlineBorder = outlineBorder // determine global min..max let mn = layer.values[0] let mx = layer.values[0] for (let i = 0; i < layer.values.length; i++) { mn = Math.min(mn, layer.values[i]) mx = Math.max(mx, layer.values[i]) } layer.global_min = mn layer.global_max = mx layer.cal_min = cal_min || 0 if (!cal_min) { layer.cal_min = mn } layer.cal_max = cal_max || 0 if (!cal_max) { layer.cal_max = mx } layer.cal_minNeg = NaN layer.cal_maxNeg = NaN layer.opacity = opacity layer.colormap = colormap layer.colormapNegative = colormapNegative layer.useNegativeCmap = useNegativeCmap return layer } // readLayer() // read brainvoyager smp format file // https://support.brainvoyager.com/brainvoyager/automation-development/84-file-formats/40-the-format-of-smp-files static async readSMP(buffer: ArrayBuffer, n_vert: number): Promise { const len = buffer.byteLength let reader = new DataView(buffer) let vers = reader.getUint16(0, true) if (vers > 5) { // assume gzip const raw = await NVUtilities.decompress(new Uint8Array(buffer)) reader = new DataView(raw.buffer) vers = reader.getUint16(0, true) buffer = raw.buffer } if (vers > 5) { log.error('Unsupported or invalid BrainVoyager SMP version ' + vers) } const nvert = reader.getUint32(2, true) if (nvert !== n_vert) { log.error('SMP file has ' + nvert + ' vertices, background mesh has ' + n_vert) } const nMaps = reader.getUint16(6, true) const scalars = new Float32Array(nvert * nMaps) const maps = [] let pos = 9 function readStr(): string { const startPos = pos while (pos < len && reader.getUint8(pos) !== 0) { pos++ } pos++ // skip null termination return new TextDecoder().decode(buffer.slice(startPos, pos - 1)) } // readStr: read variable length string // read Name of SRF const _filenameSRF = readStr() for (let i = 0; i < nMaps; i++) { const m: Partial = {} m.mapType = reader.getUint32(pos, true) pos += 4 // Read additional values only if a lag map if (vers >= 3 && m.mapType === 3) { m.nLags = reader.getUint32(pos, true) pos += 4 m.mnLag = reader.getUint32(pos, true) pos += 4 m.mxLag = reader.getUint32(pos, true) pos += 4 m.ccOverlay = reader.getUint32(pos, true) pos += 4 } m.clusterSize = reader.getUint32(pos, true) pos += 4 m.clusterCheck = reader.getUint8(pos) pos += 1 m.critThresh = reader.getFloat32(pos, true) pos += 4 m.maxThresh = reader.getFloat32(pos, true) pos += 4 if (vers >= 4) { m.includeValuesGreaterThreshMax = reader.getUint32(pos, true) pos += 4 } m.df1 = reader.getUint32(pos, true) pos += 4 m.df2 = reader.getUint32(pos, true) pos += 4 if (vers >= 5) { m.posNegFlag = reader.getUint32(pos, true) pos += 4 } else { m.posNegFlag = 3 } m.cortexBonferroni = reader.getUint32(pos, true) pos += 4 m.posMinRGB = [0, 0, 0] m.posMaxRGB = [0, 0, 0] m.negMinRGB = [0, 0, 0] m.negMaxRGB = [0, 0, 0] if (vers >= 2) { m.posMinRGB[0] = reader.getUint8(pos) pos++ m.posMinRGB[1] = reader.getUint8(pos) pos++ m.posMinRGB[2] = reader.getUint8(pos) pos++ m.posMaxRGB[0] = reader.getUint8(pos) pos++ m.posMaxRGB[1] = reader.getUint8(pos) pos++ m.posMaxRGB[2] = reader.getUint8(pos) pos++ if (vers >= 4) { m.negMinRGB[0] = reader.getUint8(pos) pos++ m.negMinRGB[1] = reader.getUint8(pos) pos++ m.negMinRGB[2] = reader.getUint8(pos) pos++ m.negMaxRGB[0] = reader.getUint8(pos) pos++ m.negMaxRGB[1] = reader.getUint8(pos) pos++ m.negMaxRGB[2] = reader.getUint8(pos) pos++ } // vers >= 4 m.enableSMPColor = reader.getUint8(pos) pos++ if (vers >= 4) { m.lut = readStr() } m.colorAlpha = reader.getFloat32(pos, true) pos += 4 } // vers >= 2 m.name = readStr() const scalarsNew = new Float32Array(buffer, pos, nvert) scalars.set(scalarsNew, i * nvert) pos += nvert * 4 maps.push(m) } // for i to nMaps return scalars } // readSMP() // read mne stc format file, not to be confused with brainvoyager stc format // https://github.com/mne-tools/mne-python/blob/main/mne/source_estimate.py#L211-L365 static readSTC(buffer: ArrayBuffer, n_vert: number): Float32Array { // https://github.com/fahsuanlin/fhlin_toolbox/blob/400cb73cda4880d9ad7841d9dd68e4e9762976bf/codes/inverse_read_stc.m // let len = buffer.byteLength; const reader = new DataView(buffer) // first 12 bytes are header // let epoch_begin_latency = reader.getFloat32(0, false); // let sample_period = reader.getFloat32(4, false); const n_vertex = reader.getInt32(8, false) if (n_vertex !== n_vert) { throw new Error('Overlay has ' + n_vertex + ' vertices, expected ' + n_vert) } // next 4*n_vertex bytes are vertex IDS let pos = 12 + n_vertex * 4 // next 4 bytes reports number of volumes/time points const n_time = reader.getUint32(pos, false) pos += 4 const f32 = new Float32Array(n_time * n_vertex) // reading all floats with .slice() would be faster, but lets handle endian-ness for (let i = 0; i < n_time * n_vertex; i++) { f32[i] = reader.getFloat32(pos, false) pos += 4 } return f32 } // readSTC() static isCurv(buffer: ArrayBuffer): boolean { const view = new DataView(buffer) // ArrayBuffer to dataview // ALWAYS big endian const sig0 = view.getUint8(0) const sig1 = view.getUint8(1) const sig2 = view.getUint8(2) if (sig0 !== 255 || sig1 !== 255 || sig2 !== 255) { utiltiesLogger.debug('Unable to recognize file type: does not appear to be FreeSurfer format.') return false } return true } // read freesurfer curv big-endian format // https://github.com/bonilhamusclab/MRIcroS/blob/master/%2BfileUtils/%2Bpial/readPial.m // http://www.grahamwideman.com/gw/brain/fs/surfacefileformats.htm static readCURV(buffer: ArrayBuffer, n_vert: number): Float32Array { const view = new DataView(buffer) // ArrayBuffer to dataview // ALWAYS big endian const sig0 = view.getUint8(0) const sig1 = view.getUint8(1) const sig2 = view.getUint8(2) const n_vertex = view.getUint32(3, false) // let num_f = view.getUint32(7, false); const n_time = view.getUint32(11, false) if (sig0 !== 255 || sig1 !== 255 || sig2 !== 255) { utiltiesLogger.debug('Unable to recognize file type: does not appear to be FreeSurfer format.') } if (n_vert !== n_vertex) { throw new Error('CURV file has different number of vertices ( ' + n_vertex + ')than mesh (' + n_vert + ')') } if (buffer.byteLength < 15 + 4 * n_vertex * n_time) { throw new Error('CURV file smaller than specified') } const f32 = new Float32Array(n_time * n_vertex) let pos = 15 // reading all floats with .slice() would be faster, but lets handle endian-ness for (let i = 0; i < n_time * n_vertex; i++) { f32[i] = view.getFloat32(pos, false) pos += 4 } let mn = f32[0] let mx = f32[0] for (let i = 0; i < f32.length; i++) { mn = Math.min(mn, f32[i]) mx = Math.max(mx, f32[i]) } // normalize const scale = 1.0 / (mx - mn) for (let i = 0; i < f32.length; i++) { f32[i] = 1.0 - (f32[i] - mn) * scale } return f32 } // readCURV() // read freesurfer Annotation file provides vertex colors // https://surfer.nmr.mgh.harvard.edu/fswiki/LabelsClutsAnnotationFiles static readANNOT(buffer: ArrayBuffer, n_vert: number, isReadColortables = false): ANNOT { const view = new DataView(buffer) // ArrayBuffer to dataview // ALWAYS big endian const n_vertex = view.getUint32(0, false) const n_vertexDecimated = this.decimateLayerVertices(n_vertex, n_vert) if (n_vert !== n_vertexDecimated) { throw new Error('ANNOT file has different number of vertices than mesh') } if (buffer.byteLength < 4 + 8 * n_vertex) { throw new Error('ANNOT file smaller than specified') } let pos = 0 // reading all floats with .slice() would be faster, but lets handle endian-ness const rgba32 = new Uint32Array(n_vertex) for (let i = 0; i < n_vertex; i++) { const idx = view.getUint32((pos += 4), false) rgba32[idx] = view.getUint32((pos += 4), false) } if (!isReadColortables) { // only read label colors, ignore labels return rgba32 } let tag = 0 try { tag = view.getInt32((pos += 4), false) } catch (error) { return rgba32 } const TAG_OLD_COLORTABLE = 1 if (tag !== TAG_OLD_COLORTABLE) { // undocumented old format return rgba32 } const ctabversion = view.getInt32((pos += 4), false) if (ctabversion > 0) { // undocumented old format return rgba32 } const maxstruc = view.getInt32((pos += 4), false) const len = view.getInt32((pos += 4), false) pos += len const num_entries = view.getInt32((pos += 4), false) if (num_entries < 1) { // undocumented old format return rgba32 } // preallocate lookuptable const LUT = { R: Array(maxstruc).fill(0), G: Array(maxstruc).fill(0), B: Array(maxstruc).fill(0), A: Array(maxstruc).fill(0), I: Array(maxstruc).fill(0), labels: Array(maxstruc).fill('') } for (let i = 0; i < num_entries; i++) { const struc = view.getInt32((pos += 4), false) const labelLen = view.getInt32((pos += 4), false) pos += 4 let txt = '' for (let c = 0; c < labelLen; c++) { const val = view.getUint8(pos++) if (val === 0) { break } txt += String.fromCharCode(val) } pos -= 4 const R = view.getInt32((pos += 4), false) const G = view.getInt32((pos += 4), false) const B = view.getInt32((pos += 4), false) const A = view.getInt32((pos += 4), false) if (struc < 0 || struc >= maxstruc) { log.warn('annot entry out of range') continue } LUT.R[struc] = R LUT.G[struc] = G LUT.B[struc] = B LUT.A[struc] = A LUT.I[struc] = (A << 24) + (B << 16) + (G << 8) + R LUT.labels[struc] = txt } // start issue1455: if ALL labels are transparent assume ALL opaque let isAllAlphaZero = true for (let i = 0; i < maxstruc; i++) { if (LUT.A[i] > 0) { isAllAlphaZero = false } } if (isAllAlphaZero) { for (let i = 0; i < maxstruc; i++) { LUT.A[i] = 255 } } // end issue1455 const scalars = new Float32Array(n_vertex) scalars.fill(-1) let nError = 0 for (let i = 0; i < n_vert; i++) { const RGB = rgba32[i] for (let c = 0; c < maxstruc; c++) { if (LUT.I[c] === RGB) { scalars[i] = c break } } // for c if (scalars[i] < 0) { nError++ scalars[i] = 0 } } if (nError > 0) { log.error(`annot vertex colors do not match ${nError} of ${n_vertex} vertices.`) } for (let i = 0; i < maxstruc; i++) { LUT.I[i] = i } const colormapLabel = cmapper.makeLabelLut(LUT) return { scalars, colormapLabel } } // readANNOT() // read BrainNet viewer format // https://www.nitrc.org/projects/bnv/ static readNV(buffer: ArrayBuffer): DefaultMeshType { // n.b. clockwise triangle winding, indexed from 1 const len = buffer.byteLength const bytes = new Uint8Array(buffer) let pos = 0 function readStr(): string { while (pos < len && bytes[pos] === 10) { pos++ } // skip blank lines const startPos = pos while (pos < len && bytes[pos] !== 10) { pos++ } pos++ // skip EOLN if (pos - startPos < 1) { return '' } return new TextDecoder().decode(buffer.slice(startPos, pos - 1)) } let nvert = 0 // 173404 346804 let ntri = 0 let v = 0 let t = 0 let positions: Float32Array let indices: Uint32Array while (pos < len) { const line = readStr() if (line.startsWith('#')) { continue } const items = line.trim().split(/\s+/) if (nvert < 1) { nvert = parseInt(items[0]) positions = new Float32Array(nvert * 3) continue } if (v < nvert * 3) { positions![v] = parseFloat(items[0]) positions![v + 1] = parseFloat(items[1]) positions![v + 2] = parseFloat(items[2]) v += 3 continue } if (ntri < 1) { ntri = parseInt(items[0]) indices = new Uint32Array(ntri * 3) continue } if (t >= ntri * 3) { break } indices![t + 2] = parseInt(items[0]) - 1 indices![t + 1] = parseInt(items[1]) - 1 indices![t + 0] = parseInt(items[2]) - 1 t += 3 } return { positions: positions!, indices: indices! } } // readNV() // read ASCII Patch File format // https://afni.nimh.nih.gov/pub/dist/doc/htmldoc/demos/Bootcamp/CD.html#cd // http://www.grahamwideman.com/gw/brain/fs/surfacefileformats.htm static readASC(buffer: ArrayBuffer): DefaultMeshType { const len = buffer.byteLength const bytes = new Uint8Array(buffer) let pos = 0 function readStr(): string { while (pos < len && bytes[pos] === 10) { pos++ } // skip blank lines const startPos = pos while (pos < len && bytes[pos] !== 10) { pos++ } pos++ // skip EOLN if (pos - startPos < 1) { return '' } return new TextDecoder().decode(buffer.slice(startPos, pos - 1)) } let line = readStr() // 1st line: '#!ascii version of lh.pial' if (!line.startsWith('#!ascii')) { log.warn('Invalid ASC mesh') } line = readStr() // 1st line: signature let items = line.trim().split(/\s+/) const nvert = parseInt(items[0]) // 173404 346804 const ntri = parseInt(items[1]) const positions = new Float32Array(nvert * 3) let j = 0 for (let i = 0; i < nvert; i++) { line = readStr() // 1st line: signature items = line.trim().split(/\s+/) positions[j] = parseFloat(items[0]) positions[j + 1] = parseFloat(items[1]) positions[j + 2] = parseFloat(items[2]) j += 3 } const indices = new Uint32Array(ntri * 3) j = 0 for (let i = 0; i < ntri; i++) { line = readStr() // 1st line: signature items = line.trim().split(/\s+/) indices[j] = parseInt(items[0]) indices[j + 1] = parseInt(items[1]) indices[j + 2] = parseInt(items[2]) j += 3 } return { positions, indices } } // readASC() // read legacy VTK format static readVTK(buffer: ArrayBuffer): VTK { const len = buffer.byteLength if (len < 20) { throw new Error('File too small to be VTK: bytes = ' + buffer.byteLength) } const bytes = new Uint8Array(buffer) let pos = 0 function readStr(isSkipBlank = true): string { if (isSkipBlank) { while (pos < len && bytes[pos] === 10) { pos++ } } // skip blank lines const startPos = pos while (pos < len && bytes[pos] !== 10) { pos++ } pos++ // skip EOLN if (pos - startPos < 1) { return '' } return new TextDecoder().decode(buffer.slice(startPos, pos - 1)) } let line = readStr() // 1st line: signature if (!line.startsWith('# vtk DataFile')) { throw new Error('Invalid VTK mesh') } line = readStr(false) // 2nd line comment, n.b. MRtrix stores empty line line = readStr() // 3rd line ASCII/BINARY if (line.startsWith('ASCII')) { return NVMeshLoaders.readTxtVTK(buffer) } else if (!line.startsWith('BINARY')) { throw new Error('Invalid VTK image, expected ASCII or BINARY ' + line) } line = readStr() // 5th line "DATASET POLYDATA" if (!line.includes('POLYDATA')) { throw new Error('Only able to read VTK POLYDATA ' + line) } line = readStr() // 6th line "POINTS 10261 float" if (!line.includes('POINTS') || (!line.includes('double') && !line.includes('float'))) { log.warn('Only able to read VTK float or double POINTS' + line) } const isFloat64 = line.includes('double') let items = line.trim().split(/\s+/) const nvert = parseInt(items[1]) // POINTS 10261 float const nvert3 = nvert * 3 const positions = new Float32Array(nvert3) const reader = new DataView(buffer) if (isFloat64) { for (let i = 0; i < nvert3; i++) { positions[i] = reader.getFloat64(pos, false) pos += 8 } } else { for (let i = 0; i < nvert3; i++) { positions[i] = reader.getFloat32(pos, false) pos += 4 } } line = readStr() // Type, "LINES 11885 " items = line.trim().split(/\s+/) const tris = [] if (items[0].includes('LINES')) { const n_count = parseInt(items[1]) // tractogaphy data: detect if borked by DiPy const posOK = pos line = readStr() // borked files "OFFSETS vtktypeint64" if (line.startsWith('OFFSETS')) { let isInt64 = false if (line.includes('int64')) { isInt64 = true } const offsetPt0 = new Uint32Array(n_count) if (isInt64) { let isOverflowInt32 = false for (let c = 0; c < n_count; c++) { let idx = reader.getInt32(pos, false) if (idx !== 0) { isOverflowInt32 = true } pos += 4 idx = reader.getInt32(pos, false) pos += 4 offsetPt0[c] = idx } if (isOverflowInt32) { log.warn('int32 overflow: JavaScript does not support int64') } } else { for (let c = 0; c < n_count; c++) { const idx = reader.getInt32(pos, false) pos += 4 offsetPt0[c] = idx } } const pts = positions return { pts, offsetPt0 } } pos = posOK // valid VTK file let npt = 0 const offsetPt0 = [] const pts = [] offsetPt0.push(npt) // 1st streamline starts at 0 for (let c = 0; c < n_count; c++) { const numPoints = reader.getInt32(pos, false) pos += 4 npt += numPoints offsetPt0.push(npt) for (let i = 0; i < numPoints; i++) { const idx = reader.getInt32(pos, false) * 3 pos += 4 pts.push(positions[idx + 0]) pts.push(positions[idx + 1]) pts.push(positions[idx + 2]) } // for numPoints: number of segments in streamline } // for n_count: number of streamlines return { pts: Float32Array.from(pts), offsetPt0: Uint32Array.from(offsetPt0) } } else if (items[0].includes('TRIANGLE_STRIPS')) { const nstrip = parseInt(items[1]) for (let i = 0; i < nstrip; i++) { const ntri = reader.getInt32(pos, false) - 2 // -2 as triangle strip is creates pts - 2 faces pos += 4 for (let t = 0; t < ntri; t++) { if (t % 2) { // preserve winding order tris.push(reader.getInt32(pos + 8, false)) tris.push(reader.getInt32(pos + 4, false)) tris.push(reader.getInt32(pos, false)) } else { tris.push(reader.getInt32(pos, false)) tris.push(reader.getInt32(pos + 4, false)) tris.push(reader.getInt32(pos + 8, false)) } pos += 4 } // for each triangle pos += 8 } // for each strip } else if (items[0].includes('POLYGONS')) { const npoly = parseInt(items[1]) const byteOffsetAfterPoly = pos const maybeOffsetsLine = readStr() if (maybeOffsetsLine.startsWith('OFFSETS')) { let isInt64 = maybeOffsetsLine.includes('int64') const offset = new Uint32Array(npoly) let is32bitOverflow = false for (let i = 0; i < npoly; i++) { if (isInt64) { if (reader.getInt32(pos, false) !== 0) { is32bitOverflow = true } pos += 4 } // skip high 32 bits offset[i] = reader.getInt32(pos, false) pos += 4 } if (!Number.isSafeInteger(npoly) || npoly >= 2147483648 || is32bitOverflow) { throw new Error(`values exceed 2GB limit`) } const connLine = readStr() if (!connLine.startsWith('CONNECTIVITY')) { throw new Error('Expected CONNECTIVITY after OFFSETS') } isInt64 = connLine.includes('int64') const numIndices = offset[npoly - 1] const connectivity = new Uint32Array(numIndices) for (let i = 0; i < numIndices; i++) { if (isInt64) { pos += 4 } connectivity[i] = reader.getInt32(pos, false) pos += 4 } for (let i = 0; i < npoly; i++) { const start = i === 0 ? 0 : offset[i - 1] const end = offset[i] for (let t = 1; t < end - start - 1; t++) { tris.push(connectivity[start]) tris.push(connectivity[start + t]) tris.push(connectivity[start + t + 1]) } } } else { // Classic binary VTK format: rewind and parse as before pos = byteOffsetAfterPoly for (let i = 0; i < npoly; i++) { const ntri = reader.getInt32(pos, false) - 2 if (i === 0 && ntri > 65535) { throw new Error('Invalid VTK binary polygons using little-endian data (MRtrix)') } pos += 4 const fx = reader.getInt32(pos, false) pos += 4 let fy = reader.getInt32(pos, false) pos += 4 for (let t = 0; t < ntri; t++) { const fz = reader.getInt32(pos, false) pos += 4 tris.push(fx, fy, fz) fy = fz } } } } else { throw new Error('Unsupported binary VTK datatype ' + items[0]) } const indices = new Uint32Array(tris) return { positions, indices } } // readVTK() static readWRL(buffer: ArrayBuffer): DefaultMeshType { const wrlText = new TextDecoder('utf-8').decode(buffer) const coordRegex = /coord\s+Coordinate\s*\{\s*point\s*\[([\s\S]*?)\]/ const indexRegex = /coordIndex\s*\[([\s\S]*?)\]/ const colorRegex = /color\s+Color\s*\{\s*color\s*\[([\s\S]*?)\]/ const coordMatch = coordRegex.exec(wrlText) const indexMatch = indexRegex.exec(wrlText) const colorMatch = colorRegex.exec(wrlText) if (!coordMatch || !indexMatch) { throw new Error('Invalid WRL file: Could not find vertices or indices.') } // Extract vertex positions const positions = new Float32Array( coordMatch[1] .trim() .split(/[\s,]+/) .map(Number) ) // Extract per-vertex colors (if they exist) let colors: Float32Array | null = null if (colorMatch) { colors = new Float32Array( colorMatch[1] .trim() .split(/[\s,]+/) .map(Number) ) const nVert = positions.length / 3 if (colors.length !== nVert * 3) { console.warn(`Unexpected color count: expected ${nVert * 3}, got ${colors.length}`) colors = null // Ignore malformed color data } } // Extract triangle indices (ignoring `-1` separators) const indices = new Uint32Array( indexMatch[1] .trim() .split(/[\s,]+/) .map(Number) .filter((v) => v !== -1) ) return { positions, indices, colors } } // readWRL() // read brainsuite DFS format // http://brainsuite.org/formats/dfs/ static readDFS(buffer: ArrayBuffer): DefaultMeshType { // Does not play with other formats: vertex positions do not use Aneterior Commissure as origin const reader = new DataView(buffer) const magic = reader.getUint32(0, true) // "DFS_" const LE = reader.getUint16(4, true) // "LE" if (magic !== 1599292996 || LE !== 17740) { log.warn('Not a little-endian brainsuite DFS mesh') } const hdrBytes = reader.getUint32(12, true) // var mdoffset = reader.getUint32(16, true); // var pdoffset = reader.getUint32(20, true); const nface = reader.getUint32(24, true) // number of triangles const nvert = reader.getUint32(28, true) // var nStrips = reader.getUint32(32, true); //deprecated // var stripSize = reader.getUint32(36, true); //deprecated // var normals = reader.getUint32(40, true); // var uvStart = reader.getUint32(44, true); const vcoffset = reader.getUint32(48, true) // vertexColor offset // var precision = reader.getUint32(52, true); // float64 orientation[4][4]; //4x4 matrix, affine transformation to world coordinates*) let pos = hdrBytes const indices = new Uint32Array(buffer, pos, nface * 3) pos += nface * 3 * 4 const positions = new Float32Array(buffer, pos, nvert * 3) // oops, triangle winding opposite of CCW convention for (let i = 0; i < nvert * 3; i += 3) { const tmp = positions[i] positions[i] = positions[i + 1] positions[i + 1] = tmp } let colors if (vcoffset >= 0) { colors = new Float32Array(buffer, vcoffset, nvert * 3) } return { positions, indices, colors } } // read surfice MZ3 format // https://github.com/neurolabusc/surf-ice/tree/master/mz3 static async readMZ3(buffer: ArrayBuffer, n_vert = 0): Promise { if (buffer.byteLength < 20) { throw new Error('File too small to be mz3: bytes = ' + buffer.byteLength) } let reader = new DataView(buffer) let _buffer = buffer // Check for gzip let magic = reader.getUint16(0, true) if (magic === 35615 || magic === 8075) { const raw = await NVUtilities.decompress(new Uint8Array(buffer)) reader = new DataView(raw.buffer) magic = reader.getUint16(0, true) _buffer = raw.buffer } const attr = reader.getUint16(2, true) const nface = reader.getUint32(4, true) let nvert = reader.getUint32(8, true) const nskip = reader.getUint32(12, true) utiltiesLogger.debug('MZ3 magic %d attr %d face %d vert %d skip %d', magic, attr, nface, nvert, nskip) if (magic !== 23117) { throw new Error('Invalid MZ3 file') } const isFace = (attr & 1) !== 0 const isVert = (attr & 2) !== 0 const isRGBA = (attr & 4) !== 0 let isSCALAR = (attr & 8) !== 0 const isDOUBLE = (attr & 16) !== 0 const isAOMAP = (attr & 32) !== 0 const isLOOKUP = (attr & 64) !== 0 utiltiesLogger.debug(`isFace=${isFace} isVert=${isVert} isRGBA=${isRGBA} isSCALAR=${isSCALAR} isDOUBLE=${isDOUBLE} isAOMAP=${isAOMAP} isLOOKUP=${isLOOKUP}`) if (attr > 127) { throw new Error('Unsupported future version of MZ3 file') } let bytesPerScalar = 4 if (isDOUBLE) { bytesPerScalar = 8 } let NSCALAR = 0 if (n_vert > 0 && !isFace && nface < 1 && !isRGBA) { isSCALAR = true } if (isSCALAR) { const nv = n_vert || nvert const FSizeWoScalars = 16 + nskip + (isFace ? nface * 12 : 0) + (isVert ? nv * 12 : 0) + (isRGBA ? nv * 4 : 0) const scalarFloats = Math.floor((_buffer.byteLength - FSizeWoScalars) / bytesPerScalar) if (nvert !== n_vert && scalarFloats % n_vert === 0) { nvert = n_vert } NSCALAR = Math.floor(scalarFloats / nvert) if (NSCALAR < 1) { log.warn('Corrupt MZ3: file reports NSCALAR but not enough bytes') isSCALAR = false } } if (nvert < 3 && n_vert < 3) { throw new Error('Not a mesh MZ3 file (maybe scalar)') } if (n_vert > 0 && n_vert !== nvert) { log.warn('Layer has ' + nvert + 'vertices, but background mesh has ' + n_vert) } let filepos = 16 + nskip const view = new DataView(_buffer) let indices: Uint32Array | null = null if (isFace) { indices = new Uint32Array(nface * 3) for (let i = 0; i < nface * 3; i++) { indices[i] = view.getUint32(filepos, true) filepos += 4 } } let positions: Float32Array | null = null if (isVert) { positions = new Float32Array(nvert * 3) for (let i = 0; i < nvert * 3; i++) { positions[i] = view.getFloat32(filepos, true) filepos += 4 } } let colors: Float32Array | null = null if (isRGBA) { colors = new Float32Array(nvert * 3) for (let i = 0; i < nvert; i++) { for (let j = 0; j < 3; j++) { colors[i * 3 + j] = view.getUint8(filepos++) / 255 } filepos++ // skip Alpha } } let scalars = new Float32Array() if (isSCALAR && NSCALAR > 0) { if (isDOUBLE) { const flt64 = new Float64Array(NSCALAR * nvert) for (let i = 0; i < NSCALAR * nvert; i++) { flt64[i] = view.getFloat64(filepos, true) filepos += 8 } scalars = Float32Array.from(flt64) } else { scalars = new Float32Array(NSCALAR * nvert) for (let i = 0; i < NSCALAR * nvert; i++) { scalars[i] = view.getFloat32(filepos, true) filepos += 4 } } } if (n_vert > 0 && isLOOKUP && isSCALAR) { // Read NSKIP bytes after 16-byte header const decoder = new TextDecoder('utf-8') const jsonBytes = new Uint8Array(_buffer, 16, nskip) const jsonText = decoder.decode(jsonBytes) const colormap = JSON.parse(jsonText) const colormapLabel = cmapper.makeLabelLut(colormap) return { scalars, colormapLabel } } if (n_vert > 0 && isRGBA && isSCALAR) { let mx = scalars[0] for (let i = 0; i < nvert; i++) { mx = Math.max(mx, scalars[i]) } const Labels: ColorMap = { R: [], G: [], B: [], A: [], I: [], labels: [] } for (let i = 0; i <= mx; i++) { for (let v = 0; v < nvert; v++) { if (i === scalars[v]) { const v3 = v * 3 Labels.I.push(i) Labels.R.push(colors![v3] * 255) Labels.G.push(colors![v3 + 1] * 255) Labels.B.push(colors![v3 + 2] * 255) Labels.A.push(255) Labels.labels!.push(`${i}`) break } } } const colormapLabel = cmapper.makeLabelLut(Labels) return { scalars, colormapLabel } } if (n_vert > 0) { return { scalars } } return { positions, indices, scalars, colors } } // read PLY format // https://en.wikipedia.org/wiki/PLY_(file_format) static readPLY(buffer: ArrayBuffer): DefaultMeshType { const len = buffer.byteLength const bytes = new Uint8Array(buffer) let pos = 0 function readStr(): string { while (pos < len && bytes[pos] === 10) { pos++ } // skip blank lines const startPos = pos while (pos < len && bytes[pos] !== 10) { pos++ } pos++ // skip EOLN if (pos - startPos < 1) { return '' } return new TextDecoder().decode(buffer.slice(startPos, pos - 1)) } let line = readStr() // 1st line: magic 'ply' if (!line.startsWith('ply')) { throw new Error('Not a valid PLY file') } line = readStr() // 2nd line: format 'format binary_little_endian 1.0' const isAscii = line.includes('ascii') function dataTypeBytes(str: string): number { if (str === 'char' || str === 'uchar' || str === 'int8' || str === 'uint8') { return 1 } if (str === 'short' || str === 'ushort' || str === 'int16' || str === 'uint16') { return 2 } if (str === 'int' || str === 'uint' || str === 'int32' || str === 'uint32' || str === 'float' || str === 'float32') { return 4 } if (str === 'double') { return 8 } throw new Error('Unknown data type: ' + str) } const isLittleEndian = line.includes('binary_little_endian') let nvert = 0 let vertIsDouble = false let vertStride = 0 // e.g. if each vertex stores xyz as float32 and rgb as uint8, stride is 15 let indexStrideBytes = 0 // "list uchar int vertex_indices" has stride 1 + 3 * 4 let indexCountBytes = 0 // if "property list uchar int vertex_index" this is 1 (uchar) let indexBytes = 0 // if "property list uchar int vertex_index" this is 4 (int) let indexPaddingBytes = 0 let nIndexPadding = 0 let nface = 0 const vertexProps: string[] = [] let currVertPropOffset = 0 let redOffset = -1 let greenOffset = -1 let blueOffset = -1 let hasColors = false while (pos < len && !line.startsWith('end_header')) { line = readStr() if (line.startsWith('comment')) { continue } // line = line.replaceAll('\t', ' '); // ?are tabs valid white space? let items = line.split(/\s/) if (line.startsWith('element vertex')) { nvert = parseInt(items[items.length - 1]) // read vertex properties: line = readStr() items = line.split(/\s/) currVertPropOffset = 0 vertexProps.length = 0 while (line.startsWith('property')) { const datatype = items[1] const propName = items[2] // record property name (for ASCII token mapping) vertexProps.push(propName) // record offsets for color components (for binary parsing) if (propName === 'red') { redOffset = currVertPropOffset hasColors = true } else if (propName === 'green') { greenOffset = currVertPropOffset hasColors = true } else if (propName === 'blue') { blueOffset = currVertPropOffset hasColors = true } if (propName === 'x' && datatype.startsWith('double')) { vertIsDouble = true } else if (propName === 'x' && !datatype.startsWith('float')) { log.error('Error: expect ply xyz to be float or double: ' + line) } const bytes = dataTypeBytes(datatype) vertStride += bytes currVertPropOffset += bytes line = readStr() items = line.split(/\s/) } } if (line.startsWith('element face')) { nface = parseInt(items[items.length - 1]) // read face properties: line = readStr() items = line.split(/\s/) while (line.startsWith('property')) { if (items[1] === 'list') { indexCountBytes = dataTypeBytes(items[2]) indexBytes = dataTypeBytes(items[3]) indexStrideBytes += indexCountBytes + 3 * indexBytes // e.g. "uchar int" is 1 + 3 * 4 bytes } else { const bytes = dataTypeBytes(items[1]) indexStrideBytes += bytes if (indexBytes === 0) { // this index property is BEFORE the list indexPaddingBytes += bytes nIndexPadding++ } } line = readStr() items = line.split(/\s/) } } } // while reading all lines of header if (isAscii) { if (nface < 1) { log.error(`Malformed ply format: faces ${nface} `) } const positions = new Float32Array(nvert * 3) let colors: Float32Array | undefined // find ascii token indices for x,y,z and rgb if present const idxX = vertexProps.indexOf('x') const idxY = vertexProps.indexOf('y') const idxZ = vertexProps.indexOf('z') const idxR = vertexProps.indexOf('red') const idxG = vertexProps.indexOf('green') const idxB = vertexProps.indexOf('blue') if (idxR !== -1 && idxG !== -1 && idxB !== -1) { colors = new Float32Array(nvert * 3) hasColors = true } let v = 0 for (let i = 0; i < nvert; i++) { line = readStr() const items = line.split(/\s/) // read xyz using property indices (fallback to 0,1,2 if not found) const tx = idxX >= 0 ? parseFloat(items[idxX]) : parseFloat(items[0]) const ty = idxY >= 0 ? parseFloat(items[idxY]) : parseFloat(items[1]) const tz = idxZ >= 0 ? parseFloat(items[idxZ]) : parseFloat(items[2]) positions[v] = tx positions[v + 1] = ty positions[v + 2] = tz if (hasColors && colors) { const rr = idxR >= 0 ? parseInt(items[idxR]) : 0 const gg = idxG >= 0 ? parseInt(items[idxG]) : 0 const bb = idxB >= 0 ? parseInt(items[idxB]) : 0 const vi = i * 3 colors[vi] = rr / 255.0 colors[vi + 1] = gg / 255.0 colors[vi + 2] = bb / 255.0 } v += 3 } let indices = new Uint32Array(nface * 3) let f = 0 for (let i = 0; i < nface; i++) { line = readStr() const items = line.split(/\s/) const nTri = parseInt(items[nIndexPadding]) - 2 if (nTri < 1) { break } // error if (f + nTri * 3 > indices.length) { const c = new Uint32Array(indices.length + indices.length) c.set(indices) indices = c.slice() } const idx0 = parseInt(items[nIndexPadding + 1]) let idx1 = parseInt(items[nIndexPadding + 2]) for (let j = 0; j < nTri; j++) { const idx2 = parseInt(items[nIndexPadding + 3 + j]) indices[f + 0] = idx0 indices[f + 1] = idx1 indices[f + 2] = idx2 idx1 = idx2 f += 3 } } if (indices.length !== f) { indices = indices.slice(0, f) } const out: DefaultMeshType = { positions, indices } if (hasColors) { // colors was created only when we detected rgb properties out.colors = typeof colors !== 'undefined' ? colors : undefined } return out } // if isAscii if (vertStride < 12 || indexCountBytes < 1 || indexBytes < 1 || nface < 1) { log.warn(`Malformed ply format: stride ${vertStride} count ${indexCountBytes} iBytes ${indexBytes} iStrideBytes ${indexStrideBytes} iPadBytes ${indexPaddingBytes} faces ${nface}`) } const reader = new DataView(buffer) let positions let colors: Float32Array | undefined if (hasColors) { colors = new Float32Array(nvert * 3) } if (pos % 4 === 0 && vertStride === 12 && isLittleEndian) { // optimization: vertices only store xyz position as float // n.b. start offset of Float32Array must be a multiple of 4 positions = new Float32Array(buffer, pos, nvert * 3) pos += nvert * vertStride // if colors are present they wouldn't be in this optimized path (vertStride would be >12) } else { positions = new Float32Array(nvert * 3) let v = 0 for (let i = 0; i < nvert; i++) { if (vertIsDouble) { positions[v] = reader.getFloat64(pos, isLittleEndian) positions[v + 1] = reader.getFloat64(pos + 8, isLittleEndian) positions[v + 2] = reader.getFloat64(pos + 16, isLittleEndian) } else { positions[v] = reader.getFloat32(pos, isLittleEndian) positions[v + 1] = reader.getFloat32(pos + 4, isLittleEndian) positions[v + 2] = reader.getFloat32(pos + 8, isLittleEndian) } if (hasColors && colors) { // read uchar rgb at recorded offsets (if set) const base = pos const r = redOffset >= 0 ? reader.getUint8(base + redOffset) : 0 const g = greenOffset >= 0 ? reader.getUint8(base + greenOffset) : 0 const b = blueOffset >= 0 ? reader.getUint8(base + blueOffset) : 0 const vi = i * 3 colors[vi] = r / 255.0 colors[vi + 1] = g / 255.0 colors[vi + 2] = b / 255.0 } v += 3 pos += vertStride } } const indices = new Uint32Array(nface * 3) // assume triangular mesh: pre-allocation optimization let isTriangular = true let j = 0 if (indexCountBytes === 1 && indexBytes === 4 && indexStrideBytes === 13) { // default mode: "list uchar int vertex_indices" without other properties for (let i = 0; i < nface; i++) { const nIdx = reader.getUint8(pos) pos += indexCountBytes if (nIdx !== 3) { isTriangular = false } indices[j] = reader.getUint32(pos, isLittleEndian) pos += 4 indices[j + 1] = reader.getUint32(pos, isLittleEndian) pos += 4 indices[j + 2] = reader.getUint32(pos, isLittleEndian) pos += 4 j += 3 } } else { // not 1:4 index data let startPos = pos for (let i = 0; i < nface; i++) { pos = startPos + indexPaddingBytes let nIdx = 0 if (indexCountBytes === 1) { nIdx = reader.getUint8(pos) } else if (indexCountBytes === 2) { nIdx = reader.getUint16(pos, isLittleEndian) } else if (indexCountBytes === 4) { nIdx = reader.getUint32(pos, isLittleEndian) } pos += indexCountBytes if (nIdx !== 3) { isTriangular = false } for (let k = 0; k < 3; k++) { if (indexBytes === 1) { indices[j] = reader.getUint8(pos) } else if (indexBytes === 2) { indices[j] = reader.getUint16(pos, isLittleEndian) } else if (indexBytes === 4) { indices[j] = reader.getUint32(pos, isLittleEndian) } j++ pos += indexBytes } startPos += indexStrideBytes } // for each face } // if not 1:4 datatype if (!isTriangular) { log.warn('Only able to read PLY meshes limited to triangles.') } const out: DefaultMeshType = { positions, indices } if (hasColors && typeof colors !== 'undefined') { out.colors = colors } return out } // readPLY() // FreeSurfer can convert meshes to ICO/TRI format text files // https://github.com/dfsp-spirit/freesurferformats/blob/434962608108c75d4337d5e7a5096e3bd4ee6ee6/R/read_fs_surface.R#L1090 // detect TRI format that uses same extension // http://paulbourke.net/dataformats/tri/ static readICO(buffer: ArrayBuffer): DefaultMeshType { const enc = new TextDecoder('utf-8') const txt = enc.decode(buffer) const lines = txt.split('\n') let header = lines[0].trim().split(/\s+/) // read line 0: header // FreeSurfer header has one item: [0]'num_verts' // Bourke header has 2 items: [0]'num_verts', [1]'num_faces' if (header.length > 1) { log.warn('This is not a valid FreeSurfer ICO/TRI mesh.') } const num_v = parseInt(header[0]) // read vertices: each line has 4 values: index, x, y, z const positions = new Float32Array(num_v * 3) // let v = 0; let line = 1 // line 0 is header for (let i = 0; i < num_v; i++) { const items = lines[line].trim().split(/\s+/) line++ // idx is indexed from 1, not 0 let idx = parseInt(items[0]) - 1 const x = parseFloat(items[1]) const y = parseFloat(items[2]) const z = parseFloat(items[3]) if (idx < 0 || idx >= num_v) { log.error('ICO vertices corrupted') break } idx *= 3 positions[idx] = x positions[idx + 1] = y positions[idx + 2] = z } // read all vertices // read faces header = lines[line].trim().split(/\s+/) line++ const num_f = parseInt(header[0]) const indices = new Uint32Array(num_f * 3) for (let i = 0; i < num_f; i++) { const items = lines[line].trim().split(/\s+/) line++ // all values indexed from 1, not 0 let idx = parseInt(items[0]) - 1 const x = parseInt(items[1]) - 1 const y = parseInt(items[2]) - 1 const z = parseInt(items[3]) - 1 if (idx < 0 || idx >= num_f) { log.error('ICO indices corrupted') break } idx *= 3 indices[idx] = x indices[idx + 1] = y indices[idx + 2] = z } // read all faces // FreeSurfer seems to enforce clockwise winding: reverse to CCW for (let j = 0; j < indices.length; j += 3) { const tri = indices[j] indices[j] = indices[j + 1] indices[j + 1] = tri } return { positions, indices } } // readICO() // While BYU and FreeSurfer GEO are related // - BYU can have multiple parts // - BYU faces not always triangular // http://www.grahamwideman.com/gw/brain/fs/surfacefileformats.htm#GeoFile // http://www.eg-models.de/formats/Format_Byu.html // https://github.com/dfsp-spirit/freesurferformats/blob/dafaf88a601dac90fa3c9aae4432f003f5344546/R/read_fs_surface.R#L924 // https://github.com/dfsp-spirit/freesurferformats/blob/434962608108c75d4337d5e7a5096e3bd4ee6ee6/R/read_fs_surface.R#L1144 // n.b. AFNI uses the '.g' extension for this format 'ConvertSurface -i_gii L.surf.gii -o_byu L' static readGEO(buffer: ArrayBuffer, isFlipWinding = false): DefaultMeshType { const enc = new TextDecoder('utf-8') const txt = enc.decode(buffer) const lines = txt.split('\n') const header = lines[0].trim().split(/\s+/) // read line 0: header // header[0]='nparts', [1]'npoints/vertices', [2]'npolys/faces', [3]'nconnects' const num_p = parseInt(header[0]) let num_v = parseInt(header[1]) let num_f = parseInt(header[2]) const num_c = parseInt(header[3]) if (num_p > 1 || num_c !== num_f * 3) { log.warn('Multi-part BYU/GEO header or not a triangular mesh.') } // skip line 1: it is redundant (contains number of faces once more) // next read the vertices (points) const pts = [] num_v *= 3 // each vertex has three components (x,y,z) let v = 0 let line = 2 // line 0 and 1 are header while (v < num_v) { const items = lines[line].trim().split(/\s+/) line++ for (let i = 0; i < items.length; i++) { pts.push(parseFloat(items[i])) v++ if (v >= num_v) { break } } // for each item } // read all vertices // next read faces (triangles) const t: number[] = [] num_f *= 3 // each triangle has three vertices (i,j,k) let f = 0 while (f < num_f) { const items = lines[line].trim().split(/\s+/) line++ for (let i = 0; i < items.length; i++) { t.push(Math.abs(parseInt(items[i])) - 1) f++ if (f >= num_f) { break } } // for each item } // read all faces // FreeSurfer seems to enforce clockwise winding: reverse to CCW if (isFlipWinding) { for (let j = 0; j < t.length; j += 3) { const tri = t[j] t[j] = t[j + 1] t[j + 1] = tri } } // return results const positions = new Float32Array(pts) const indices = new Uint32Array(t) return { positions, indices } } // readGEO() // read OFF format // https://en.wikipedia.org/wiki/OFF_(file_format) static readOFF(buffer: ArrayBuffer): DefaultMeshType { const enc = new TextDecoder('utf-8') const txt = enc.decode(buffer) const lines = txt.split('\n') // var n = lines.length; const pts = [] const t = [] let i = 0 // first line signature "OFF", but R freesurfer package uses "# OFF" if (!lines[i].includes('OFF')) { log.warn('File does not start with OFF') } else { i++ } let items = lines[i].trim().split(/\s+/) const num_v = parseInt(items[0]) const num_f = parseInt(items[1]) i++ for (let j = 0; j < num_v; j++) { const str = lines[i] items = str.trim().split(/\s+/) pts.push(parseFloat(items[0])) pts.push(parseFloat(items[1])) pts.push(parseFloat(items[2])) i++ } for (let j = 0; j < num_f; j++) { const str = lines[i] items = str.trim().split(/\s+/) const n = parseInt(items[0]) if (n !== 3) { log.warn('Only able to read OFF files with triangular meshes') } t.push(parseInt(items[1])) t.push(parseInt(items[2])) t.push(parseInt(items[3])) i++ } const positions = new Float32Array(pts) const indices = new Uint32Array(t) return { positions, indices } } // readOFF() static readOBJMNI(buffer: ArrayBuffer): DefaultMeshType { // Support MNI 'P'olygon mesh format // n.b. uses same .obj extension as WaveFront OBJ meshes // https://bigbrain.loris.ca/main.php?test_name=brainsurfaces // http://www.bic.mni.mcgill.ca/users/mishkin/mni_obj_format.pdf // https://pages.stat.wisc.edu/~mchung/softwares/mesh/mesh.html // https://github.com/aces/brainbrowser/tree/master const enc = new TextDecoder('utf-8') const txt = enc.decode(buffer) const items = txt.trim().split(/\s*,\s*|\s+/) if (items.length < 1 || items[0] !== 'P') { log.warn('This is not a valid MNI OBJ mesh.') } let j = 6 const nVert = parseInt(items[j++]) const nVertX3 = nVert * 3 const positions = new Float32Array(nVertX3) for (let i = 0; i < nVertX3; i++) { positions[i] = parseFloat(items[j++]) } j += nVertX3 const nTri = parseInt(items[j++]) const colour_flag = parseInt(items[j++]) if (nTri < 1 || colour_flag < 0 || colour_flag > 2) { log.warn('This is not a valid MNI OBJ mesh.') } let num_c = 1 if (colour_flag === 1) { num_c = nTri } else if (colour_flag === 1) { num_c = nVert } j += num_c * 4 j += nTri const nTriX3 = nTri * 3 const indices = new Uint32Array(nTriX3) for (let i = 0; i < nTriX3; i++) { indices[i] = parseInt(items[j++]) } return { positions, indices } } // readOBJMNI() static async readOBJ(buffer: ArrayBuffer): Promise { // WaveFront OBJ format const headerBytes = new Uint8Array(buffer, 0, 2) if (headerBytes[0] === 0x1f && headerBytes[1] === 0x8b) { // gzip signature 0x1F8B in little and big endian buffer = await NVUtilities.decompressToBuffer(new Uint8Array(buffer)) } const enc = new TextDecoder('utf-8') const txt = enc.decode(buffer) if (txt[0] === 'P') { return this.readOBJMNI(buffer) } const lines = txt.split('\n') const n = lines.length const pts = [] const tris = [] for (let i = 0; i < n; i++) { const str = lines[i] if (str[0] === 'v' && str[1] === ' ') { // 'v ' but not 'vt' or 'vn' const items = str.trim().split(/\s+/) pts.push(parseFloat(items[1])) pts.push(parseFloat(items[2])) pts.push(parseFloat(items[3])) // v 0 -0.5 -0 } if (str[0] === 'f') { const items = str.trim().split(/\s+/) const new_t = items.length - 3 // number of new triangles created if (new_t < 1) { break } // error let tn = items[1].split('/') const t0 = parseInt(tn[0]) - 1 // first vertex tn = items[2].split('/') let tprev = parseInt(tn[0]) - 1 // previous vertex for (let j = 0; j < new_t; j++) { tn = items[3 + j].split('/') const tcurr = parseInt(tn[0]) - 1 // current vertex tris.push(t0) tris.push(tprev) tris.push(tcurr) tprev = tcurr } } } // for all lines const positions = new Float32Array(pts) const indices = new Uint32Array(tris) // next vertex indices are supposed to be from 1, not 0 let min = indices[0] let max = indices[0] for (let i = 1; i < indices.length; i++) { if (indices[i] < min) { min = indices[i] } if (indices[i] > max) { max = indices[i] } } if (max - min + 1 > positions.length / 3) { throw new Error('Not a valid OBJ file') } for (let i = 0; i < indices.length; i++) { indices[i] -= min } return { positions, indices } } // readOBJ() // read FreeSurfer big endian format static readFreeSurfer(buffer: ArrayBuffer): DefaultMeshType { const bytes = new Uint8Array(buffer) if (bytes[0] === 35 && bytes[1] === 33 && bytes[2] === 97) { return NVMeshLoaders.readASC(buffer) // "#!ascii version" } const view = new DataView(buffer) // ArrayBuffer to dataview const sig0 = view.getUint32(0, false) const sig1 = view.getUint32(4, false) if (sig0 !== 4294966883 || sig1 !== 1919246708) { utiltiesLogger.debug('Unable to recognize file type: does not appear to be FreeSurfer format.') } let offset = 0 while (view.getUint8(offset) !== 10) { offset++ } offset += 2 let nv = view.getUint32(offset, false) // number of vertices offset += 4 let nf = view.getUint32(offset, false) // number of faces offset += 4 nv *= 3 // each vertex has 3 positions: XYZ const positions = new Float32Array(nv) for (let i = 0; i < nv; i++) { positions[i] = view.getFloat32(offset, false) offset += 4 } nf *= 3 // each triangle face indexes 3 triangles const indices = new Uint32Array(nf) for (let i = 0; i < nf; i++) { indices[i] = view.getUint32(offset, false) offset += 4 } // read undocumented footer // https://github.com/nipy/nibabel/blob/8fea2a8e50aaf4d8b0d4bfff7a21b132914120ee/nibabel/freesurfer/io.py#L58C5-L58C9 const head0 = view.getUint32(offset, false) offset += 4 let isHeadOK = head0 === 20 if (!isHeadOK) { // read two more int32s const head1 = view.getUint32(offset, false) offset += 4 const head2 = view.getUint32(offset, false) offset += 4 isHeadOK = head0 === 2 && head1 === 0 && head2 === 20 } if (!isHeadOK) { log.warn('Unknown FreeSurfer Mesh extension code.') } else { const footer = new TextDecoder().decode(buffer.slice(offset)).trim() const strings = footer.split('\n') for (let s = 0; s < strings.length; s++) { if (!strings[s].startsWith('cras')) { continue } const cras = strings[s].split('=')[1].trim() const FreeSurferTranlate = cras.split(' ').map(Number) const nvert = Math.floor(positions.length / 3) let i = 0 for (let v = 0; v < nvert; v++) { positions[i] += FreeSurferTranlate[0] i++ positions[i] += FreeSurferTranlate[1] i++ positions[i] += FreeSurferTranlate[2] i++ } } } return { positions, indices } } // readFreeSurfer() // read brainvoyager SRF format // https://support.brainvoyager.com/brainvoyager/automation-development/84-file-formats/344-users-guide-2-3-the-format-of-srf-files static async readSRF(buffer: ArrayBuffer): Promise { const bytes = new Uint8Array(buffer) if (bytes[0] === 35 && bytes[1] === 33 && bytes[2] === 97) { // .srf also used for freesurfer https://brainder.org/research/brain-for-blender/ return NVMeshLoaders.readASC(buffer) // "#!ascii version" } if (bytes[0] === 31 && bytes[1] === 139) { // handle .srf.gz const raw = await NVUtilities.decompress(new Uint8Array(buffer)) buffer = raw.buffer } const reader = new DataView(buffer) const ver = reader.getFloat32(0, true) const nVert = reader.getUint32(8, true) const nTri = reader.getUint32(12, true) const oriX = reader.getFloat32(16, true) const oriY = reader.getFloat32(20, true) const oriZ = reader.getFloat32(24, true) const positions = new Float32Array(nVert * 3) // BrainVoyager does not use Talairach coordinates for XYZ! // read X component of each vertex let pos = 28 let j = 1 // BrainVoyager X is Talairach Y for (let i = 0; i < nVert; i++) { positions[j] = -reader.getFloat32(pos, true) + oriX j += 3 // read one of 3 components: XYZ pos += 4 // read one float32 } // read Y component of each vertex j = 2 // BrainVoyager Y is Talairach Z for (let i = 0; i < nVert; i++) { positions[j] = -reader.getFloat32(pos, true) + oriY j += 3 // read one of 3 components: XYZ pos += 4 // read one float32 } // read Z component of each vertex j = 0 // BrainVoyager Z is Talairach X for (let i = 0; i < nVert; i++) { positions[j] = -reader.getFloat32(pos, true) + oriZ j += 3 // read one of 3 components: XYZ pos += 4 // read one float32 } // not sure why normals are stored, does bulk up file size pos = 28 + 4 * 6 * nVert // each vertex has 6 float32s: XYZ for position and normal // read concave and convex colors: const rVex = reader.getFloat32(pos, true) const gVex = reader.getFloat32(pos + 4, true) const bVex = reader.getFloat32(pos + 8, true) const rCave = reader.getFloat32(pos + 16, true) const gCave = reader.getFloat32(pos + 20, true) const bCave = reader.getFloat32(pos + 24, true) pos += 8 * 4 // skip 8 floats (RGBA convex/concave) // read per-vertex colors const colors = new Float32Array(nVert * 3) const colorsIdx = new Uint32Array(buffer, pos, nVert) j = 0 // convert RGBA -> RGB for (let i = 0; i < nVert; i++) { const c = colorsIdx[i] if (c > 1056964608) { colors[j + 0] = ((c >> 16) & 0xff) / 255 colors[j + 1] = ((c >> 8) & 0xff) / 255 colors[j + 2] = (c & 0xff) / 255 } if (c === 0) { // convex colors[j + 0] = rVex colors[j + 1] = gVex colors[j + 2] = bVex } if (c === 1) { // concave colors[j + 0] = rCave colors[j + 1] = gCave colors[j + 2] = bCave } j += 3 } pos += nVert * 4 // MeshColor, sequence of color indices // not sure why nearest neighbors are stored, slower and bigger files for (let i = 0; i < nVert; i++) { const nNearest = reader.getUint32(pos, true) pos += 4 + 4 * nNearest } const indices = new Uint32Array(nTri * 3) for (let i = 0; i < nTri * 3; i++) { indices[i] = reader.getInt32(pos, true) pos += 4 } if (ver !== 4) { log.warn('Not valid SRF') } return { positions, indices, colors } } // readSRF() // read STL ASCII format file // http://paulbourke.net/dataformats/stl/ static readTxtSTL(buffer: ArrayBuffer): DefaultMeshType { const enc = new TextDecoder('utf-8') const txt = enc.decode(buffer) const lines = txt.split('\n') if (!lines[0].startsWith('solid')) { throw new Error('Not a valid STL file') } const pts = [] for (let i = 1; i < lines.length; i++) { if (!lines[i].includes('vertex')) { continue } const items = lines[i].trim().split(/\s+/) for (let j = 1; j < items.length; j++) { pts.push(parseFloat(items[j])) } } const npts = Math.floor(pts.length / 3) // each vertex has x,y,z if (npts * 3 !== pts.length) { throw new Error('Unable to parse ASCII STL file.') } const positions = new Float32Array(pts) const indices = new Uint32Array(npts) for (let i = 0; i < npts; i++) { indices[i] = i } return { positions, indices } } // readTxtSTL() // read STL format, nb this format does not reuse vertices // https://en.wikipedia.org/wiki/STL_(file_format) static readSTL(buffer: ArrayBuffer): DefaultMeshType { if (buffer.byteLength < 80 + 4 + 50) { throw new Error('File too small to be STL: bytes = ' + buffer.byteLength) } const reader = new DataView(buffer) const sig = reader.getUint32(0, true) if (sig === 1768714099) { return NVMeshLoaders.readTxtSTL(buffer) } const ntri = reader.getUint32(80, true) const ntri3 = 3 * ntri if (buffer.byteLength < 80 + 4 + ntri * 50) { throw new Error('STL file too small to store triangles = ' + ntri) } const indices = new Uint32Array(ntri3) const positions = new Float32Array(ntri3 * 3) let pos = 80 + 4 + 12 let v = 0 // vertex for (let i = 0; i < ntri; i++) { for (let j = 0; j < 9; j++) { positions[v] = reader.getFloat32(pos, true) v += 1 pos += 4 } pos += 14 // 50 bytes for triangle, only 36 used for position } for (let i = 0; i < ntri3; i++) { indices[i] = i } return { positions, indices } } // readSTL() static decimateLayerVertices(nVertLayer: number, nVertMesh: number): number { // downsample layer vertices if the mesh has been decimated if (nVertLayer % nVertMesh === 0) { return nVertLayer } const V0 = 12 const orderLayer = Math.round(Math.log((nVertLayer - 2) / (V0 - 2)) / Math.log(4)) const orderMesh = Math.round(Math.log((nVertMesh - 2) / (V0 - 2)) / Math.log(4)) // sanity check const nVLayer = Math.pow(4, orderLayer) * (V0 - 2) + 2 const nVMesh = Math.pow(4, orderMesh) * (V0 - 2) + 2 if (nVLayer !== nVertLayer || nVMesh !== nVertMesh) { return nVertLayer } return nVertMesh } // read NIfTI2 format with embedded CIfTI // this variation very specific to connectome workbench // https://brainder.org/2015/04/03/the-nifti-2-file-format/ static async readNII2(buffer: ArrayBuffer, n_vert = 0, anatomicalStructurePrimary = ''): Promise { let scalars: Float32Array | Int32Array | Int16Array | Uint8Array = new Float32Array() const len = buffer.byteLength let isLittleEndian = true const reader = new DataView(buffer) let magic = reader.getUint16(0, isLittleEndian) if (magic === 469893120) { isLittleEndian = false magic = reader.getUint16(0, isLittleEndian) } if (magic !== 540) { throw new Error('Not a valid NIfTI-2 dataset') } const voxoffset = Number(reader.getBigInt64(168, isLittleEndian)) const scl_slope = reader.getFloat64(176, isLittleEndian) const scl_inter = reader.getFloat64(184, isLittleEndian) if (scl_slope !== 1 || scl_inter !== 0) { log.warn('ignoring scale slope and intercept') } const intent_code = reader.getUint32(504, isLittleEndian) const datatype = reader.getUint16(12, isLittleEndian) if (datatype !== 2 && datatype !== 4 && datatype !== 8 && datatype !== 16) { throw new Error('Unsupported NIfTI datatype ' + datatype) } let nvert = 1 const dim = [1, 1, 1, 1, 1, 1, 1, 1] for (let i = 1; i < 8; i++) { dim[i] = Math.max(Number(reader.getBigInt64(16 + i * 8, isLittleEndian)), 1) nvert *= dim[i] } if (intent_code >= 3000 && intent_code <= 3099 && voxoffset > 580) { // CIFTI ConnDenseScalar let indexOffset = 0 let indexCount = 0 let surfaceNumberOfVertices = 0 let brainStructure = '' let vertexIndices: Uint32Array = new Uint32Array() const bytes = new Uint8Array(buffer) let pos = 552 function readStrX(): string { while (pos < len && bytes[pos] === 10) { pos++ } // skip blank lines const startPos = pos while (pos < len && bytes[pos] !== 10) { pos++ } pos++ // skip EOLN if (pos - startPos < 1) { return '' } return new TextDecoder().decode(buffer.slice(startPos, pos - 1)).trim() } function readStr(): string { // concatenate lines to return tag <...> let line = readStrX() if (!line.startsWith('<') || line.endsWith('>')) { return line } while (pos < len && !line.endsWith('>')) { line += readStrX() } return line } let line: string function readNumericTag(TagName: string, asString = false): string | number { // Tag 'Dim1' will return 3 for Dim1="3" const tpos = line.indexOf(TagName) if (tpos < 0) { return 1 } const spos = line.indexOf('"', tpos) + 1 const epos = line.indexOf('"', spos) const str = line.slice(spos, epos) if (asString) { return str } return parseInt(str) } // readNumericTag const nFrame4D = dim[5] // number of timepoints/frames per vertex const scalars = new Float32Array(n_vert * nFrame4D) // eslint-disable-next-line no-unmodified-loop-condition -- pos is modified within readStr while (pos < len) { line = readStr() if (line.includes('')) { break } if (line.includes('')) { line = readStr() } if (!line.startsWith('') || !line.endsWith('')) { log.warn('Unable to find CIfTI ') return scalars } line = line.slice(15, -16) const items = line.trim().split(/\s+/) if (items.length < indexCount) { log.error('Error parsing VertexIndices') } vertexIndices = new Uint32Array(indexCount) for (let i = 0; i < indexCount; i++) { vertexIndices[i] = parseInt(items[i]) } } // read if (surfaceNumberOfVertices === 0 || vertexIndices.length === 0) { log.warn('Unable to find CIfTI structure that matches the mesh.') return scalars } if (datatype !== 16) { log.warn('Only able to read float32 CIfTI (only known datatype).') return scalars } const vals = new Float32Array(indexCount * nFrame4D) const off = voxoffset + nFrame4D * indexOffset * 4 for (let i = 0; i < indexCount * nFrame4D; i++) { vals[i] = reader.getFloat32(off + i * 4, isLittleEndian) } // } let j = 0 for (let i = 0; i < indexCount; i++) { for (let f = 0; f < nFrame4D; f++) { scalars[vertexIndices[i] + f * n_vert] = vals[j] j++ } } log.debug('CIfTI diagnostics', surfaceNumberOfVertices, brainStructure, indexOffset, indexCount, indexOffset, anatomicalStructurePrimary) // return scalars } // is CIfTI nvert = this.decimateLayerVertices(nvert, n_vert) if (nvert % n_vert !== 0) { throw new Error('Vertices in layer (' + nvert + ') is not a multiple of number of vertices (' + n_vert + ')') } if (isLittleEndian) { // block read native endian if (datatype === 16) { scalars = new Float32Array(buffer, voxoffset, nvert) } else if (datatype === 8) { scalars = new Int32Array(buffer, voxoffset, nvert) } else if (datatype === 4) { scalars = new Int16Array(buffer, voxoffset, nvert) } } else { // if isLittleEndian if (datatype === 16) { scalars = new Float32Array(nvert) for (let i = 0; i < nvert; i++) { scalars[i] = reader.getFloat32(voxoffset + i * 4, isLittleEndian) } } else if (datatype === 8) { scalars = new Int32Array(nvert) for (let i = 0; i < nvert; i++) { scalars[i] = reader.getInt32(voxoffset + i * 4, isLittleEndian) } } else if (datatype === 4) { scalars = new Int16Array(nvert) for (let i = 0; i < nvert; i++) { scalars[i] = reader.getInt16(voxoffset + i * 2, isLittleEndian) } } } // if isLittleEndian else big end if (datatype === 2) { scalars = new Uint8Array(buffer, voxoffset, nvert) } return scalars } // readNII2() // read NIfTI1/2 as vertex colors // https://brainder.org/2012/09/23/the-nifti-file-format/#:~:text=In%20the%20nifti%20format%2C%20the,seventh%2C%20are%20for%20other%20uses. static async readNII(buffer: ArrayBuffer, pts: Float32Array, anatomicalStructurePrimary = ''): Promise { const n_mesh_vert = pts.length / 3 // TODO clean up number types let scalars: Float32Array | Int32Array | Int16Array | Uint8Array = new Float32Array() let isLittleEndian = true let reader = new DataView(buffer) let magic = reader.getUint16(0, isLittleEndian) if (magic === 540 || magic === 469893120) { return NVMeshLoaders.readNII2(buffer, n_mesh_vert, anatomicalStructurePrimary) } if (magic === 23553) { isLittleEndian = false magic = reader.getUint16(0, isLittleEndian) } if (magic !== 348) { // gzip signature 0x1F8B in little and big endian const raw = await NVUtilities.decompress(new Uint8Array(buffer)) reader = new DataView(raw.buffer) buffer = raw.buffer magic = reader.getUint16(0, isLittleEndian) if (magic === 540 || magic === 469893120) { return NVMeshLoaders.readNII2(buffer, n_mesh_vert, anatomicalStructurePrimary) } if (magic === 23553) { isLittleEndian = false magic = reader.getUint16(0, isLittleEndian) } } if (magic !== 348) { log.error('Not a valid NIfTI image.') } const voxoffset = reader.getFloat32(108, isLittleEndian) const scl_slope = reader.getFloat32(112, isLittleEndian) const scl_inter = reader.getFloat32(116, isLittleEndian) const qform_code = reader.getUint16(252, isLittleEndian) const sform_code = reader.getUint16(254, isLittleEndian) const datatype = reader.getUint16(70, isLittleEndian) if (datatype !== 2 && datatype !== 4 && datatype !== 8 && datatype !== 16) { throw new Error('Unsupported NIfTI datatype ' + datatype) } const sform = mat4.create() for (let i = 0; i < 12; i++) { sform[i] = reader.getFloat32(280 + i * 4, isLittleEndian) } let n_vox = 1 const dim = new Array(8) for (let i = 0; i < 8; i++) { dim[i] = reader.getUint16(40 + i * 2, isLittleEndian) if (i < 1) { continue } n_vox *= dim[i] || 1 } let is3D = false let nvertD = this.decimateLayerVertices(n_vox, n_mesh_vert) if (nvertD % n_mesh_vert !== 0) { if (dim[0] >= 3 && dim[1] > 1 && dim[2] > 1 && dim[3] > 1) { is3D = true nvertD = dim[1] * dim[2] * dim[3] } else { throw new Error('Voxels in layer (' + n_vox + ') is not a multiple of number of vertices (' + n_mesh_vert + ')') } } n_vox = nvertD if (isLittleEndian) { // block read native endian if (datatype === 16) { scalars = new Float32Array(buffer, voxoffset, n_vox) } else if (datatype === 8) { scalars = new Int32Array(buffer, voxoffset, n_vox) } else if (datatype === 4) { scalars = new Int16Array(buffer, voxoffset, n_vox) } } else { // if isLittleEndian if (datatype === 16) { scalars = new Float32Array(n_vox) for (let i = 0; i < n_vox; i++) { scalars[i] = reader.getFloat32(voxoffset + i * 4, isLittleEndian) } } else if (datatype === 8) { scalars = new Int32Array(n_vox) for (let i = 0; i < n_vox; i++) { scalars[i] = reader.getInt32(voxoffset + i * 4, isLittleEndian) } } else if (datatype === 4) { scalars = new Int16Array(n_vox) for (let i = 0; i < n_vox; i++) { scalars[i] = reader.getInt16(voxoffset + i * 2, isLittleEndian) } } } // if isLittleEndian else big end if (datatype === 2) { scalars = new Uint8Array(buffer, voxoffset, n_vox) } if (Number.isFinite(scl_slope) && Number.isFinite(scl_inter) && scl_slope !== 0 && (scl_slope !== 1 || scl_inter !== 0)) { const f32 = new Float32Array(n_vox) for (let i = 0; i < n_vox; i++) { f32[i] = scalars[i] * scl_slope + scl_inter } scalars = f32 } let mn = scalars[0] let mx = mn for (let i = 0; i < n_vox; i++) { if (isNaN(scalars[i])) { scalars[i] = 0.0 } mn = Math.min(mn, scalars[i]) mx = Math.max(mx, scalars[i]) } log.debug(`Layer Range ${mn}..${mx} slope ${scl_slope} inter ${scl_inter}`) if (is3D) { const f32 = new Float32Array(n_mesh_vert) // Sample voxel intensities at mesh vertices log.warn('Sampling voxel intensities at mesh vertices (assumes precise alignment).') if (qform_code > sform_code || sform_code <= 0) { log.warn(`Requires valid sform (sform_code = ${sform_code})`) } const sformInv = mat4.create() mat4.invert(sformInv, sform) function transformMat4homogeneous(xyz: number[], m: mat4): number[] { const out = [0, 0, 0] out[0] = m[0] * xyz[0] + m[1] * xyz[1] + m[2] * xyz[2] + m[3] out[1] = m[4] * xyz[0] + m[5] * xyz[1] + m[6] * xyz[2] + m[7] out[2] = m[8] * xyz[0] + m[9] * xyz[1] + m[10] * xyz[2] + m[11] return out } const nx = dim[1] const ny = dim[2] const nz = dim[3] const nxy = nx * ny let j = 0 let k = 0 let nOK = 0 while (k < n_mesh_vert) { const xyz = [pts[j], pts[j + 1], pts[j + 2]] const ijk = transformMat4homogeneous(xyz, sformInv) j += 3 k += 1 // todo read voxel scalar const ixf = Math.floor(ijk[0]) const iyf = Math.floor(ijk[1]) const izf = Math.floor(ijk[2]) const ixc = Math.ceil(ijk[0]) const iyc = Math.ceil(ijk[1]) const izc = Math.ceil(ijk[2]) // bounds check: valid ranges are 0..(nx-1), 0..(ny-1), 0..(nz-1) if (ixf < 0 || ixc >= nx || iyf < 0 || iyc >= ny || izf < 0 || izc >= nz) { continue } const base = ixf + iyf * nx + izf * nxy const vs = new Float32Array(8) vs[0] = scalars[base] vs[1] = scalars[base + 1] vs[2] = scalars[base + nx] vs[3] = scalars[base + nx + 1] vs[4] = scalars[base + nxy] vs[5] = scalars[base + nxy + 1] vs[6] = scalars[base + nxy + nx] vs[7] = scalars[base + nxy + nx + 1] // choose max (unrolled for speed) let maxv = vs[0] let minv = maxv for (let i = 1; i < 8; i++) { maxv = Math.max(maxv, vs[i]) minv = Math.min(minv, vs[i]) } if (Math.abs(minv) > maxv) { maxv = minv } f32[k - 1] = maxv nOK++ } const fractionOK = nOK / n_mesh_vert if (fractionOK < 0.1) { log.warn(`${nOK} of ${n_mesh_vert} vertices in range (${(fractionOK * 100).toFixed(1)}%)`) } scalars = f32 } return scalars } // readNII(); // read MGH format as vertex colors (not voxel-based image) // https://surfer.nmr.mgh.harvard.edu/fswiki/FsTutorial/MghFormat static async readMGH(buffer: ArrayBuffer, n_vert = 0, isReadColortables = false): Promise { let reader = new DataView(buffer) let raw = buffer if (reader.getUint8(0) === 31 && reader.getUint8(1) === 139) { const decompressed = await NVUtilities.decompress(new Uint8Array(buffer)) raw = new ArrayBuffer(decompressed.byteLength) new Uint8Array(raw).set(new Uint8Array(decompressed)) reader = new DataView(decompressed.buffer) } const version = reader.getInt32(0, false) const width = Math.max(1, reader.getInt32(4, false)) const height = Math.max(1, reader.getInt32(8, false)) const depth = Math.max(1, reader.getInt32(12, false)) const nframes = Math.max(1, reader.getInt32(16, false)) const mtype = reader.getInt32(20, false) let voxoffset = 284 // ALWAYS fixed header size const isLittleEndian = false // ALWAYS byte order is BIG ENDIAN if (version !== 1 || mtype < 0 || mtype > 4) { log.warn('Not a valid MGH file') } let nvert = width * height * depth * nframes let scalars: AnyNumberArray = [] nvert = this.decimateLayerVertices(nvert, n_vert) if (nvert % n_vert !== 0) { log.warn('Vertices in layer (' + nvert + ') is not a multiple of number of vertices (' + n_vert + ')') return scalars } if (mtype === 3) { scalars = new Float32Array(nvert) for (let i = 0; i < nvert; i++) { scalars[i] = reader.getFloat32(voxoffset + i * 4, isLittleEndian) } } else if (mtype === 1) { scalars = new Int32Array(nvert) for (let i = 0; i < nvert; i++) { scalars[i] = reader.getInt32(voxoffset + i * 4, isLittleEndian) } } else if (mtype === 4) { scalars = new Int16Array(nvert) for (let i = 0; i < nvert; i++) { scalars[i] = reader.getInt16(voxoffset + i * 2, isLittleEndian) } } else if (mtype === 0) { scalars = new Uint8Array(buffer, voxoffset, nvert) } if (!isReadColortables) { return scalars } // next: read footer let bytesPerVertex = 4 if (mtype === 4) { bytesPerVertex = 2 } if (mtype === 0) { bytesPerVertex = 1 } voxoffset += bytesPerVertex * nvert voxoffset += 4 * 4 // skip TR, FlipAngle, TE, TI, FOV const TAG_OLD_COLORTABLE = 1 const TAG_OLD_USEREALRAS = 2 // const TAG_CMDLINE = 3; // const TAG_USEREALRAS = 4; // const TAG_COLORTABLE = 5; // const TAG_GCAMORPH_GEOM = 10; // const TAG_GCAMORPH_TYPE = 11; // const TAG_GCAMORPH_LABELS = 12; const TAG_OLD_SURF_GEOM = 20 // const TAG_SURF_GEOM = 21; const TAG_OLD_MGH_XFORM = 30 // const TAG_MGH_XFORM = 31; // const TAG_GROUP_AVG_SURFACE_AREA = 32; // const TAG_AUTO_ALIGN = 33; // const TAG_SCALAR_DOUBLE = 40; // const TAG_PEDIR = 41; // const TAG_MRI_FRAME = 42; // const TAG_FIELDSTRENGTH = 43; // const TAG_ORIG_RAS2VOX = 44; const nBytes = raw.byteLength let colormapLabel: LUT while (voxoffset < nBytes - 8) { // let vx = voxoffset; const tagType = reader.getInt32((voxoffset += 4), isLittleEndian) let plen = 0 switch (tagType) { case TAG_OLD_MGH_XFORM: // doesn't include null plen = reader.getInt32((voxoffset += 4), isLittleEndian) - 1 break case TAG_OLD_SURF_GEOM: // these don't take lengths at all case TAG_OLD_USEREALRAS: plen = 0 break case TAG_OLD_COLORTABLE: plen = 0 // CTABreadFromBinary() { let version = reader.getInt32((voxoffset += 4), isLittleEndian) if (version > 0) { log.warn('unsupported CTABreadFromBinaryV1') return scalars } version = -version if (version !== 2) { log.warn('CTABreadFromBinary: unknown version') return scalars } // CTABreadFromBinaryV2() follows const nentries = reader.getInt32((voxoffset += 4), isLittleEndian) if (nentries < 0) { log.warn('CTABreadFromBinaryV2: nentries was ', nentries) return scalars } // skip the file name const len = reader.getInt32((voxoffset += 4), isLittleEndian) voxoffset += len const num_entries_to_read = reader.getInt32((voxoffset += 4), isLittleEndian) if (num_entries_to_read < 0) { return scalars } // Allocate our table. const Labels: ColorMap = { R: [], G: [], B: [], A: [], I: [], labels: [] } for (let i = 0; i < num_entries_to_read; i++) { const structure = reader.getInt32((voxoffset += 4), isLittleEndian) const labelLen = reader.getInt32((voxoffset += 4), isLittleEndian) let pos = voxoffset + 4 let txt = '' for (let c = 0; c < labelLen; c++) { const val = reader.getUint8(pos++) if (val === 0) { break } txt += String.fromCharCode(val) } // for labelLen voxoffset += labelLen const R = reader.getInt32((voxoffset += 4), isLittleEndian) const G = reader.getInt32((voxoffset += 4), isLittleEndian) const B = reader.getInt32((voxoffset += 4), isLittleEndian) const A = 255 - reader.getInt32((voxoffset += 4), isLittleEndian) Labels.I.push(structure) Labels.R.push(R) Labels.G.push(G) Labels.B.push(B) Labels.A.push(A) Labels.labels!.push(txt) // break } // for num_entries_to_read colormapLabel = cmapper.makeLabelLut(Labels) } break default: plen = reader.getInt32((voxoffset += 8), isLittleEndian) } voxoffset += plen } return { scalars, colormapLabel: colormapLabel! // TODO can we guarantee this? } } // readMGH() // read X3D format mesh // https://en.wikipedia.org/wiki/X3D static readX3D(buffer: ArrayBuffer): X3D { // n.b. only plain text ".x3d", not binary ".x3db" // beware: The values of XML attributes are delimited by either single or double quotes const len = buffer.byteLength if (len < 20) { throw new Error('File too small to be X3D: bytes = ' + len) } const bytes = new Uint8Array(buffer) let pos = 0 function readStr(): string { while (pos < len && bytes[pos] !== 60) { pos++ } const startP = pos while (pos < len && bytes[pos] !== 62) { pos++ } const endP = pos return new TextDecoder().decode(buffer.slice(startP, endP + 1)).trim() } let line = readStr() // detect XML signature: ' = {} function readAppearance(): void { if (!line.endsWith('/>')) { if (line.startsWith('')) { // eslint-disable-next-line no-unmodified-loop-condition -- modified within readStr while (pos < len && !line.endsWith('')) { line += readStr() } } else { // eslint-disable-next-line no-unmodified-loop-condition -- modified within readStr while (pos < len && !line.endsWith('/>')) { line += readStr() } } } const ref = readStringTag('USE') if (ref.length > 1) { if (ref in appearanceStyles) { rgba = appearanceStyles[ref as keyof typeof appearanceStyles] as number[] } else { log.warn('Unable to find DEF for ' + ref) } return } const diffuseColor = readNumericTag('diffuseColor') as number[] if (diffuseColor.length < 3) { return } rgba[0] = Math.round(diffuseColor[0] * 255) rgba[1] = Math.round(diffuseColor[1] * 255) rgba[2] = Math.round(diffuseColor[2] * 255) const def = readStringTag('DEF') if (def.length < 1) { return } appearanceStyles[def] = rgba } // eslint-disable-next-line no-unmodified-loop-condition -- modified within readStr while (pos < len) { line = readStr() rgba = rgbaGlobal.slice() if (line.startsWith('= 0) { indices.push(coordIndex[triStart] + idx0) indices.push(coordIndex[j - 1] + idx0) indices.push(coordIndex[j - 0] + idx0) j += 1 } else { j += 3 triStart = j - 2 } } } else { while (j < coordIndex.length) { if (coordIndex[j] >= 0) { indices.push(coordIndex[j - 2] + idx0) indices.push(coordIndex[j - 1] + idx0) indices.push(coordIndex[j - 0] + idx0) j += 1 } else { // coordIndex[j] === -1, next polygon j += 3 } } } // n.b. positions.push(...point) can generate "Maximum call stack size exceeded" positions = [...positions, ...point] const npt = Math.floor(point.length / 3) const rgbas = Array(npt).fill(rgba).flat() if (color.length === npt * 3) { // colors are rgb 0..1, rgbas are RGBA 0..255 let c3 = 0 let c4 = 0 for (let i = 0; i < npt; i++) { for (let j2 = 0; j2 < 3; j2++) { rgbas[c4] = Math.round(color[c3] * 255.0) c3++ c4++ } c4++ } } rgba255 = [...rgba255, ...rgbas] } else if (height < 0.0) { // sphere NiivueObject3D.makeColoredSphere(positions, indices, rgba255, radius, translation, rgba) } else { // https://www.andre-gaschler.com/rotationconverter/ const r = mat4.create() // rotation mat4x4 mat4.fromRotation(r, rotation[3], [rotation[0], rotation[1], rotation[2]]) const pti = vec4.fromValues(0, -height * 0.5, 0, 1) const ptj = vec4.fromValues(0, +height * 0.5, 0, 1) vec4.transformMat4(pti, pti, r) vec4.transformMat4(ptj, ptj, r) vec4.add(pti, pti, translation) vec4.add(ptj, ptj, translation) const pti3 = vec3.fromValues(pti[0], pti[1], pti[2]) const ptj3 = vec3.fromValues(ptj[0], ptj[1], ptj[2]) // https://www.web3d.org/specifications/X3Dv4Draft/ISO-IEC19775-1v4-CD/Part01/components/geometry3D.html#Cylinder NiivueObject3D.makeColoredCylinder(positions, indices, rgba255, pti3, ptj3, radius, rgba) } } // while { let len = buffer.byteLength if (len < 20) { throw new Error('File too small to be GII: bytes = ' + len) } let chars = new TextDecoder('ascii').decode(buffer) if (chars[0].charCodeAt(0) === 31) { // raw GIFTI saved as .gii.gz is smaller than gz GIFTI due to base64 overhead const raw = await NVUtilities.decompress(new Uint8Array(buffer)) buffer = raw.buffer chars = new TextDecoder('ascii').decode(raw.buffer) } let pos = 0 function readXMLtag(): XmlTag { let isEmptyTag = true let startPos = pos while (isEmptyTag) { // while (pos < len && chars[pos] === 10) pos++; //skip blank lines while (pos < len && chars[pos] !== '<') { pos++ } // find tag start symbol: '<' e.g. "" startPos = pos while (pos < len && chars[pos] !== '>') { pos++ } // find tag end symbol: '>' e.g. "" isEmptyTag = chars[pos - 1] === '/' // empty tag ends "/>" e.g. "
" if (startPos + 1 < len && chars[startPos + 1] === '/') { // skip end tag "= len) { break } } const tagString = new TextDecoder().decode(buffer.slice(startPos + 1, pos)).trim() const startTag = tagString.split(' ')[0].trim() // ignore declarations https://stackoverflow.com/questions/60801060/what-does-mean-in-xml const contentStartPos = pos let contentEndPos = pos let endPos = pos if (chars[startPos + 1] !== '?' && chars[startPos + 1] !== '!') { // ignore declarations "' contentEndPos = chars.indexOf(endTag, contentStartPos) endPos = contentEndPos + endTag.length - 1 } // content // a b c d // a: startPos // b: contentStartPos // c: contentEndPos // d: endPos return { name: tagString, startPos, contentStartPos, contentEndPos, endPos } //, 'startTagLastPos': startTagLastPos, 'endTagFirstPos': endTagFirstPos, 'endTagLastPos': endTagLastPos]; } let tag = readXMLtag() if (!tag.name.startsWith('?xml')) { throw new Error('readGII: Invalid XML file') } while (!tag.name.startsWith('GIFTI') && tag.endPos < len) { tag = readXMLtag() } if (!tag.name.startsWith('GIFTI') || tag.contentStartPos === tag.contentEndPos) { throw new Error('readGII: XML file does not include GIFTI tag') } len = tag.contentEndPos // only read contents of GIfTI tag let positions = new Float32Array() let indices = new Uint32Array() let scalars = new Float32Array() let anatomicalStructurePrimary = '' let isIdx = false let isPts = false let isVectors = false let isColMajor = false let Dims = [1, 1, 1] const FreeSurferTranlate = [0, 0, 0] // https://gist.github.com/alexisthual/f0b2f9eb2a67b8f61798f2c138dda981 let dataType = 0 // let isLittleEndian = true; let isGzip = false let isASCII = false let nvert = 0 // FreeSurfer versions after 20221225 disambiguate if transform has been applied // "./mris_convert --to-scanner" store raw vertex positions in scanner space, so transforms should be ignored. // FreeSurfer versions after 20221225 report that the transform is applied by reporting: // 1) { tag = readXMLtag() if (tag.name.startsWith('Label Key')) { line = tag.name Labels.I.push(readNumericTag('Key=')) Labels.R.push(Math.round(255 * readNumericTag('Red=', true))) Labels.G.push(Math.round(255 * readNumericTag('Green=', true))) Labels.B.push(Math.round(255 * readNumericTag('Blue=', true))) Labels.A.push(Math.round(255 * readNumericTag('Alpha', true))) line = new TextDecoder().decode(buffer.slice(tag.contentStartPos + 1, tag.contentEndPos)).trim() Labels.labels!.push(readBracketTag(' Int32 if (dataType === 32) { dataType = 16 } // float64 -> float32 if (dataType === 8) { datBin = new Int32Array(nvert) for (let v = 0; v < nvert; v++) { datBin[v] = parseInt(lines[v]) } } if (dataType === 16) { datBin = new Float32Array(nvert) for (let v = 0; v < nvert; v++) { datBin[v] = parseFloat(lines[v]) } } } else if (typeof Buffer === 'undefined') { // raw.gii function base64ToUint8(base64: string): Uint8Array { const binary_string = 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 } if (isGzip) { const datZ = base64ToUint8(line.slice()) datBin = await NVUtilities.decompress(new Uint8Array(datZ)) } else { datBin = base64ToUint8(line.slice()) } } else { // if Buffer not defined if (isGzip) { const datZ = Buffer.from(line.slice(), 'base64') datBin = await NVUtilities.decompress(new Uint8Array(datZ)) } else { datBin = Buffer.from(line.slice(), 'base64') } } if (isPts) { if (dataType !== 16) { log.warn('expect positions as FLOAT32') } positions = new Float32Array(datBin!.buffer) // TODO can we guarantee this? if (isColMajor) { const tmp = positions.slice() const np = tmp.length / 3 let j = 0 for (let p = 0; p < np; p++) { for (let i = 0; i < 3; i++) { positions[j] = tmp[i * np + p] j++ } } } // isColMajor } else if (isIdx) { if (dataType !== 8) { log.warn('expect indices as INT32') } indices = new Uint32Array(datBin!.buffer) if (isColMajor) { const tmp = indices.slice() const np = tmp.length / 3 let j = 0 for (let p = 0; p < np; p++) { for (let i = 0; i < 3; i++) { indices[j] = tmp[i * np + p] j++ } } } // isColMajor } else { // not position or indices: assume scalars NIFTI_INTENT_NONE nvert = Dims[0] * Dims[1] * Dims[2] if (n_vert !== 0) { if (nvert % n_vert !== 0) { log.warn('Number of vertices in scalar overlay (' + nvert + ') does not match mesh (' + n_vert + ')') } } function Float32Concat(first: Float32Array, second: Float32Array): Float32Array { const firstLength = first.length const result = new Float32Array(firstLength + second.length) result.set(first) result.set(second, firstLength) return result } // Float32Concat() let scalarsNew if (dataType === 2) { const scalarsInt = new Uint8Array(datBin!.buffer) scalarsNew = Float32Array.from(scalarsInt) } else if (dataType === 8) { const scalarsInt = new Int32Array(datBin!.buffer) scalarsNew = Float32Array.from(scalarsInt) } else if (dataType === 16) { scalarsNew = new Float32Array(datBin!.buffer) } else if (dataType === 32) { const scalarFloat = new Float64Array(datBin!.buffer) scalarsNew = Float32Array.from(scalarFloat) } else { throw new Error(`Invalid dataType: ${dataType}`) } scalars = Float32Concat(scalars as Float32Array, scalarsNew) } continue } if (tag.name.trim() === 'DataSpace') { line = new TextDecoder().decode(buffer.slice(tag.contentStartPos + 1, tag.contentEndPos)).trim() if (line.includes('NIFTI_XFORM_SCANNER_ANAT')) { isDataSpaceScanner = true } } if (tag.name.trim() === 'MD') { line = new TextDecoder().decode(buffer.slice(tag.contentStartPos + 1, tag.contentEndPos)).trim() if (line.includes('AnatomicalStructurePrimary') && line.includes('CDATA[')) { anatomicalStructurePrimary = readBracketTag(' 1) { // issue1441 const hasAlpha = Labels.A.some((a) => a > 0) if (!hasAlpha) { Labels.A.fill(255) } colormapLabel = cmapper.makeLabelLut(Labels) } if (n_vert > 0) { return { scalars, colormapLabel, anatomicalStructurePrimary } } if (positions!.length > 2 && !isDataSpaceScanner && (FreeSurferTranlate[0] !== 0 || FreeSurferTranlate[1] !== 0 || FreeSurferTranlate[2] !== 0)) { nvert = Math.floor(positions!.length / 3) let i = 0 for (let v = 0; v < nvert; v++) { positions![i] += FreeSurferTranlate[0] i++ positions[i] += FreeSurferTranlate[1] i++ positions[i] += FreeSurferTranlate[2] i++ } } // issue416: apply FreeSurfer translation return { positions, indices, scalars, colormapLabel, anatomicalStructurePrimary } // MatrixData } // readGII() }