@niivue/niivue
Version:
minimal webgl2 nifti image viewer
1,546 lines (1,520 loc) • 128 kB
text/typescript
import { mat4, vec4, vec3 } from 'gl-matrix'
import { log } from './logger.js'
import { NVUtilities, Zip } from './nvutilities.js'
import { ColorMap, LUT, cmapper } from './colortables.js'
import { NiivueObject3D } from './niivue-object3D.js'
import { NVMesh, NVMeshLayer, NVMeshLayerDefaults } from './nvmesh.js'
import {
ANNOT,
DefaultMeshType,
GII,
// Layer,
MGH,
MZ3,
SmpMap,
TCK,
TRACT,
TRK,
TT,
TRX,
VTK,
X3D,
XmlTag,
AnyNumberArray
} from './nvmesh-types.js'
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 '<network'
function readNumericTag(TagName: string): number {
// Tag 'Dim1' will return 3 for Dim1="3"
const pos = line.indexOf(TagName)
if (pos < 0) {
return 0
}
const spos = line.indexOf('"', pos) + 1
const epos = line.indexOf('"', spos)
const str = line.slice(spos, epos)
return parseInt(str)
}
const n_tracts = readNumericTag('N_tracts=')
if (!line.startsWith('<network') || n_tracts < 1) {
log.warn('This is not a valid niml.tract file ' + line)
}
let npt = 0
const offsetPt0 = []
offsetPt0.push(npt) // 1st streamline starts at 0
const pts = []
const bundleTags = []
for (let t = 0; t < n_tracts; t++) {
line = readStr() // <tracts ...
const new_tracts = readNumericTag('ni_dimen=')
const bundleTag = readNumericTag('Bundle_Tag=')
const isLittleEndian = line.includes('binary.lsbfirst')
for (let i = 0; i < new_tracts; i++) {
// let id = reader.getUint32(pos, isLittleEndian);
pos += 4
const new_pts = reader.getUint32(pos, isLittleEndian) / 3
pos += 4
for (let j = 0; j < new_pts; j++) {
pts.push(reader.getFloat32(pos, isLittleEndian))
pos += 4
pts.push(-reader.getFloat32(pos, isLittleEndian))
pos += 4
pts.push(reader.getFloat32(pos, isLittleEndian))
pos += 4
}
npt += new_pts
offsetPt0.push(npt)
bundleTags.push(bundleTag) // each streamline associated with tract
}
line = readStr() // </tracts>
}
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<TT> {
// 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
// read TRX format tractogram
// https://github.com/tee-ar-ex/trx-spec/blob/master/specifications.md
static async readTRX(buffer: ArrayBuffer): Promise<TRX> {
// 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 dpg = []
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 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')) {
dpg.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')
}
offsetPt0[noff] = npt / 3 // solve fence post problem, offset for final streamline
return {
pts,
offsetPt0: new Uint32Array(offsetPt0),
dpg,
dps,
dpv,
header
}
} // readTRX()
// 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<TRK> {
// 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<NVMeshLayer | undefined> {
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) {
if (ext !== 'TSF') {
throw new Error('readLayer for streamlines only supports TSF files.')
}
const npt = nvmesh.pts.length / 3
// 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('.')
}
}
// 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, n_vert, 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<Float32Array> {
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<SmpMap> = {}
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
}
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])
p