@niivue/niivue 0.44.0 → 0.44.2-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/nvdocument.ts CHANGED
@@ -46,7 +46,8 @@ export enum DRAG_MODE {
46
46
  measurement = 2,
47
47
  pan = 3,
48
48
  slicer3D = 4,
49
- callbackOnly = 5
49
+ callbackOnly = 5,
50
+ roiSelection = 6
50
51
  }
51
52
 
52
53
  // make mutable type
@@ -96,6 +97,7 @@ export type NVConfigOptions = {
96
97
  multiplanarPadPixels: number
97
98
  // @deprecated
98
99
  multiplanarForceRender: boolean
100
+ multiplanarEqualSize: boolean
99
101
  multiplanarShowRender: SHOW_RENDER
100
102
  isRadiologicalConvention: boolean
101
103
  // string to allow infinity
@@ -139,10 +141,18 @@ export type NVConfigOptions = {
139
141
  renderOverlayBlend: number
140
142
  sliceMosaicString: string
141
143
  centerMosaic: boolean
144
+ penSize: number
142
145
  clickToSegment: boolean
143
146
  clickToSegmentRadius: number
144
- clickToSegmentSteps: number
145
147
  clickToSegmentBright: boolean
148
+ clickToSegmentAutoIntensity: boolean // new option, but keep clickToSegmentBright for backwards compatibility
149
+ clickToSegmentIntensityMax: number // also covers NaN
150
+ clickToSegmentIntensityMin: number // also covers NaN
151
+ clickToSegmentPercent: number
152
+ clickToSegmentMaxDistanceMM: number // max distance in mm to consider for click to segment flood fill
153
+ // selection box outline thickness
154
+ selectionBoxLineThickness: number
155
+ selectionBoxIsOutline: boolean
146
156
  }
147
157
 
148
158
  export const DEFAULT_OPTIONS: NVConfigOptions = {
@@ -177,6 +187,7 @@ export const DEFAULT_OPTIONS: NVConfigOptions = {
177
187
  multiplanarPadPixels: 0,
178
188
  // @deprecated
179
189
  multiplanarForceRender: false,
190
+ multiplanarEqualSize: false,
180
191
  multiplanarShowRender: SHOW_RENDER.AUTO, // auto is the same behaviour as multiplanarForceRender: false
181
192
  isRadiologicalConvention: false,
182
193
  meshThicknessOn2D: Infinity,
@@ -210,13 +221,25 @@ export const DEFAULT_OPTIONS: NVConfigOptions = {
210
221
  renderOverlayBlend: 1.0,
211
222
  sliceMosaicString: '',
212
223
  centerMosaic: false,
224
+ penSize: 1, // in voxels, since all drawing is done using bitmap indices
213
225
  clickToSegment: false,
214
- clickToSegmentRadius: 2,
215
- clickToSegmentSteps: 10,
216
- clickToSegmentBright: true
226
+ clickToSegmentRadius: 3, // in mm
227
+ clickToSegmentBright: true,
228
+ clickToSegmentAutoIntensity: false, // new option, but keep clickToSegmentBright for backwards compatibility
229
+ clickToSegmentIntensityMax: NaN, // NaN will use auto threshold (default flood fill behavior from before)
230
+ clickToSegmentIntensityMin: NaN, // NaN will use auto threshold (default flood fill behavior from before)
231
+ // 0 will use auto threshold (default flood fill behavior from before)
232
+ // Take the voxel intensity at the click point and use this percentage +/- to threshold the flood fill operation.
233
+ // If greater than 0, clickedVoxelIntensity +/- clickedVoxelIntensity * clickToSegmentPercent will be used
234
+ // for the clickToSegmentIntensityMin and clickToSegmentIntensityMax values.
235
+ clickToSegmentPercent: 0,
236
+ clickToSegmentMaxDistanceMM: Number.POSITIVE_INFINITY, // default value is infinity for backwards compatibility with flood fill routine.
237
+ selectionBoxLineThickness: 4,
238
+ selectionBoxIsOutline: false
217
239
  }
218
240
 
219
241
  type SceneData = {
242
+ gamma: number
220
243
  azimuth: number
221
244
  elevation: number
222
245
  crosshairPos: vec3
@@ -230,6 +253,7 @@ type SceneData = {
230
253
  }
231
254
 
232
255
  export const INITIAL_SCENE_DATA = {
256
+ gamma: 1.0,
233
257
  azimuth: 110,
234
258
  elevation: 10,
235
259
  crosshairPos: vec3.create(),
@@ -255,6 +279,7 @@ export type Scene = {
255
279
  pan2Dxyzmm: vec4
256
280
  _elevation?: number
257
281
  _azimuth?: number
282
+ gamma?: number
258
283
  }
259
284
 
260
285
  export type DocumentData = {
@@ -566,6 +591,12 @@ export class NVDocument {
566
591
  }
567
592
 
568
593
  data.labels = [...this.data.labels]
594
+
595
+ // remove any handlers
596
+ for (const label of data.labels) {
597
+ delete label.onClick
598
+ }
599
+
569
600
  data.customData = this.customData
570
601
 
571
602
  // volumes
@@ -1631,7 +1631,7 @@ export class NVImage {
1631
1631
  hdr.datatypeCode = NiiDataType.DT_FLOAT64
1632
1632
  break
1633
1633
  default:
1634
- throw new Error('Unsupported NRRD data type: ' + items[0])
1634
+ throw new Error('Unsupported MHA data type: ' + items[0])
1635
1635
  }
1636
1636
  }
1637
1637
  if (line.startsWith('ObjectType') && !items[0].includes('Image')) {
@@ -1653,6 +1653,9 @@ export class NVImage {
1653
1653
  [mat33[2], mat33[5], mat33[8], offset[2]],
1654
1654
  [0, 0, 0, 1]
1655
1655
  ]
1656
+ while (bytes[pos] === 10) {
1657
+ pos++
1658
+ }
1656
1659
  hdr.vox_offset = pos
1657
1660
  if (isDetached && pairedImgData) {
1658
1661
  if (isGz) {
@@ -3369,7 +3372,7 @@ export class NVImage {
3369
3372
  * read a 3D slab of voxels from a volume
3370
3373
  * @param voxStart - first row, column and slice (RAS order) for selection
3371
3374
  * @param voxEnd - final row, column and slice (RAS order) for selection
3372
- * @param dataType - array data type. Options: 'same' (default), 'uint8', 'float32'
3375
+ * @param dataType - array data type. Options: 'same' (default), 'uint8', 'float32', 'scaled', 'normalized', 'windowed'
3373
3376
  * @returns the an array where ret[0] is the voxel values and ret[1] is dimension of selection
3374
3377
  * @see {@link https://niivue.github.io/niivue/features/slab_selection.html | live demo usage}
3375
3378
  */
@@ -3394,7 +3397,12 @@ export class NVImage {
3394
3397
  let dt = this.hdr!.datatypeCode
3395
3398
  if (dataType === 'uint8') {
3396
3399
  dt = NiiDataType.DT_UINT8
3397
- } else if (dataType === 'float32') {
3400
+ } else if (
3401
+ dataType === 'float32' ||
3402
+ dataType === 'scaled' ||
3403
+ dataType === 'normalized' ||
3404
+ dataType === 'windowed'
3405
+ ) {
3398
3406
  dt = NiiDataType.DT_FLOAT32
3399
3407
  }
3400
3408
  if (dt === NiiDataType.DT_UINT8) {
@@ -3424,6 +3432,24 @@ export class NVImage {
3424
3432
  }
3425
3433
  }
3426
3434
  }
3435
+ if (dataType === 'scaled' || dataType === 'normalized' || dataType === 'windowed') {
3436
+ for (let i = 0; i < img.length; i++) {
3437
+ img[i] = img[i] * this.hdr.scl_slope + this.hdr.scl_inter
3438
+ }
3439
+ }
3440
+ if (dataType === 'normalized' || dataType === 'windowed') {
3441
+ let mn = this.cal_min
3442
+ let mx = this.cal_max
3443
+ if (dataType === 'normalized') {
3444
+ mn = this.global_min
3445
+ mx = this.global_max
3446
+ }
3447
+ const scale = 1 / (mx - mn)
3448
+ for (let i = 0; i < img.length; i++) {
3449
+ img[i] = (img[i] - mn) * scale
3450
+ img[i] = Math.max(Math.min(img[i], 1), 0)
3451
+ }
3452
+ }
3427
3453
  return [img, slabDims]
3428
3454
  } // getVolumeData()
3429
3455
 
@@ -373,10 +373,13 @@ following conditions are met:
373
373
  return m
374
374
  }
375
375
 
376
- function str2Buffer(str: string): number[] {
376
+ function str2Buffer(str: string, maxLen: number = 80): number[] {
377
377
  // emulate node.js Buffer.from
378
+ // remove characters than could be used for shell expansion
379
+ str = str.replace(/[`$]/g, '')
378
380
  const bytes = []
379
- for (let i = 0; i < str.length; i++) {
381
+ const len = Math.min(maxLen, str.length)
382
+ for (let i = 0; i < len; i++) {
380
383
  const char = str.charCodeAt(i)
381
384
  bytes.push(char & 0xff)
382
385
  }
@@ -384,70 +387,79 @@ function str2Buffer(str: string): number[] {
384
387
  }
385
388
 
386
389
  // save NIfTI header into UINT8 array for saving to disk
387
- export function hdrToArrayBuffer(hdr: NiftiHeader, isDrawing8 = false): Uint8Array {
390
+ export function hdrToArrayBuffer(hdr: NiftiHeader, isDrawing8 = false, isInputEndian = false): Uint8Array {
388
391
  const SHORT_SIZE = 2
389
392
  const FLOAT32_SIZE = 4
390
-
393
+ let isLittleEndian = true
394
+ if (isInputEndian) {
395
+ isLittleEndian = hdr.littleEndian
396
+ }
391
397
  const byteArray = new Uint8Array(348)
392
398
  const view = new DataView(byteArray.buffer)
393
399
  // sizeof_hdr
394
- view.setInt32(0, 348, hdr.littleEndian)
400
+ view.setInt32(0, 348, isLittleEndian)
395
401
 
396
402
  // data_type, db_name, extents, session_error, regular are not used
397
-
403
+ // regular set to 'r' (ASCII 114) for Analyze compatibility
404
+ view.setUint8(38, 114)
398
405
  // dim_info
399
406
  view.setUint8(39, hdr.dim_info)
400
407
 
401
408
  // dims
402
409
  for (let i = 0; i < 8; i++) {
403
- view.setUint16(40 + SHORT_SIZE * i, hdr.dims[i], hdr.littleEndian)
410
+ view.setUint16(40 + SHORT_SIZE * i, hdr.dims[i], isLittleEndian)
404
411
  }
405
412
 
406
413
  // intent_p1, intent_p2, intent_p3
407
- view.setFloat32(56, hdr.intent_p1, hdr.littleEndian)
408
- view.setFloat32(60, hdr.intent_p2, hdr.littleEndian)
409
- view.setFloat32(64, hdr.intent_p3, hdr.littleEndian)
414
+ view.setFloat32(56, hdr.intent_p1, isLittleEndian)
415
+ view.setFloat32(60, hdr.intent_p2, isLittleEndian)
416
+ view.setFloat32(64, hdr.intent_p3, isLittleEndian)
410
417
  // intent_code, datatype, bitpix, slice_start
411
- view.setInt16(68, hdr.intent_code, hdr.littleEndian)
418
+ view.setInt16(68, hdr.intent_code, isLittleEndian)
412
419
  if (isDrawing8) {
413
- view.setInt16(70, 2, hdr.littleEndian) // 2 = DT_UINT8
414
- view.setInt16(72, 8, hdr.littleEndian)
420
+ view.setInt16(70, 2, isLittleEndian) // 2 = DT_UINT8
421
+ view.setInt16(72, 8, isLittleEndian)
415
422
  } else {
416
- view.setInt16(70, hdr.datatypeCode, hdr.littleEndian)
417
- view.setInt16(72, hdr.numBitsPerVoxel, hdr.littleEndian)
423
+ view.setInt16(70, hdr.datatypeCode, isLittleEndian)
424
+ view.setInt16(72, hdr.numBitsPerVoxel, isLittleEndian)
418
425
  }
419
- view.setInt16(74, hdr.slice_start, hdr.littleEndian)
426
+ view.setInt16(74, hdr.slice_start, isLittleEndian)
420
427
 
421
428
  // pixdim[8], vox_offset, scl_slope, scl_inter
422
429
  for (let i = 0; i < 8; i++) {
423
- view.setFloat32(76 + FLOAT32_SIZE * i, hdr.pixDims[i], hdr.littleEndian)
430
+ view.setFloat32(76 + FLOAT32_SIZE * i, hdr.pixDims[i], isLittleEndian)
424
431
  }
425
432
  if (isDrawing8) {
426
- view.setFloat32(108, 352, hdr.littleEndian)
427
- view.setFloat32(112, 1.0, hdr.littleEndian)
428
- view.setFloat32(116, 0.0, hdr.littleEndian)
433
+ view.setFloat32(108, 352, isLittleEndian)
434
+ view.setFloat32(112, 1.0, isLittleEndian)
435
+ view.setFloat32(116, 0.0, isLittleEndian)
429
436
  } else {
430
- view.setFloat32(108, hdr.vox_offset, hdr.littleEndian)
431
- view.setFloat32(112, hdr.scl_slope, hdr.littleEndian)
432
- view.setFloat32(116, hdr.scl_inter, hdr.littleEndian)
437
+ // view.setFloat32(108, hdr.vox_offset, isLittleEndian)
438
+ view.setFloat32(108, 352, isLittleEndian)
439
+ view.setFloat32(112, hdr.scl_slope, isLittleEndian)
440
+ view.setFloat32(116, hdr.scl_inter, isLittleEndian)
433
441
  }
434
442
  // slice_end
435
- view.setInt16(120, hdr.slice_end, hdr.littleEndian)
443
+ view.setInt16(120, hdr.slice_end, isLittleEndian)
436
444
 
437
445
  // slice_code, xyzt_units
438
446
  view.setUint8(122, hdr.slice_code)
439
- view.setUint8(123, hdr.xyzt_units)
447
+ if (hdr.xyzt_units === 0) {
448
+ view.setUint8(123, 10)
449
+ } else {
450
+ view.setUint8(123, hdr.xyzt_units)
451
+ }
440
452
 
441
453
  // cal_max, cal_min, slice_duration, toffset
442
454
  if (isDrawing8) {
443
- view.setFloat32(124, 0, hdr.littleEndian)
444
- view.setFloat32(128, 0, hdr.littleEndian)
455
+ view.setFloat32(124, 0, isLittleEndian)
456
+ view.setFloat32(128, 0, isLittleEndian)
445
457
  } else {
446
- view.setFloat32(124, hdr.cal_max, hdr.littleEndian)
447
- view.setFloat32(128, hdr.cal_min, hdr.littleEndian)
458
+ view.setFloat32(124, hdr.cal_max, isLittleEndian)
459
+ view.setFloat32(128, hdr.cal_min, isLittleEndian)
448
460
  }
449
- view.setFloat32(132, hdr.slice_duration, hdr.littleEndian)
450
- view.setFloat32(136, hdr.toffset, hdr.littleEndian)
461
+ view.setFloat32(132, hdr.slice_duration, isLittleEndian)
462
+ view.setFloat32(136, hdr.toffset, isLittleEndian)
451
463
 
452
464
  // glmax, glmin are unused
453
465
 
@@ -457,27 +469,34 @@ export function hdrToArrayBuffer(hdr: NiftiHeader, isDrawing8 = false): Uint8Arr
457
469
  // node.js: byteArray.set(Buffer.from(hdr.aux_file), 228);
458
470
  byteArray.set(str2Buffer(hdr.aux_file), 228)
459
471
  // qform_code, sform_code
460
- view.setInt16(252, hdr.qform_code, hdr.littleEndian)
461
- view.setInt16(254, hdr.sform_code, hdr.littleEndian)
472
+ view.setInt16(252, hdr.qform_code, isLittleEndian)
473
+ // if sform unknown, assume NIFTI_XFORM_SCANNER_ANAT
474
+ if (hdr.sform_code < 1 || hdr.sform_code < 1) {
475
+ view.setInt16(254, 1, isLittleEndian)
476
+ } else {
477
+ view.setInt16(254, hdr.sform_code, isLittleEndian)
478
+ }
462
479
 
463
480
  // quatern_b, quatern_c, quatern_d, qoffset_x, qoffset_y, qoffset_z, srow_x[4], srow_y[4], and srow_z[4]
464
- view.setFloat32(256, hdr.quatern_b, hdr.littleEndian)
465
- view.setFloat32(260, hdr.quatern_c, hdr.littleEndian)
466
- view.setFloat32(264, hdr.quatern_d, hdr.littleEndian)
467
- view.setFloat32(268, hdr.qoffset_x, hdr.littleEndian)
468
- view.setFloat32(272, hdr.qoffset_y, hdr.littleEndian)
469
- view.setFloat32(276, hdr.qoffset_z, hdr.littleEndian)
481
+ view.setFloat32(256, hdr.quatern_b, isLittleEndian)
482
+ view.setFloat32(260, hdr.quatern_c, isLittleEndian)
483
+ view.setFloat32(264, hdr.quatern_d, isLittleEndian)
484
+ view.setFloat32(268, hdr.qoffset_x, isLittleEndian)
485
+ view.setFloat32(272, hdr.qoffset_y, isLittleEndian)
486
+ view.setFloat32(276, hdr.qoffset_z, isLittleEndian)
470
487
  const flattened = hdr.affine.flat()
471
488
  // we only want the first three rows
472
489
  for (let i = 0; i < 12; i++) {
473
- view.setFloat32(280 + FLOAT32_SIZE * i, flattened[i], hdr.littleEndian)
490
+ view.setFloat32(280 + FLOAT32_SIZE * i, flattened[i], isLittleEndian)
474
491
  }
475
492
  // node.js https://www.w3schools.com/nodejs/met_buffer_from.asp
476
493
  // intent_name and magic
477
494
  // node.js byteArray.set(Buffer.from(hdr.intent_name), 328);
478
- byteArray.set(str2Buffer(hdr.intent_name), 328)
495
+ // byteArray.set(str2Buffer(hdr.intent_name), 328)
479
496
  // node.js byteArray.set(Buffer.from(hdr.magic), 344);
480
- byteArray.set(str2Buffer(hdr.magic), 344)
497
+ // byteArray.set(str2Buffer(hdr.magic), 344)
498
+ view.setInt32(344, 3222382, true) // "n+1\0"
499
+
481
500
  return byteArray
482
501
  // return byteArray.buffer;
483
502
  }
package/src/nvlabel.ts CHANGED
@@ -10,6 +10,29 @@ export enum LabelLineTerminator {
10
10
  RING = 'ring'
11
11
  }
12
12
 
13
+ export enum LabelAnchorFlag {
14
+ NONE = 0,
15
+ LEFT = 1 << 0,
16
+ CENTER = 1 << 1,
17
+ RIGHT = 1 << 2,
18
+ TOP = 1 << 3,
19
+ MIDDLE = 1 << 4,
20
+ BOTTOM = 1 << 5
21
+ }
22
+
23
+ export enum LabelAnchorPoint {
24
+ NONE = LabelAnchorFlag.NONE,
25
+ TOPLEFT = LabelAnchorFlag.TOP | LabelAnchorFlag.LEFT,
26
+ TOPCENTER = LabelAnchorFlag.TOP | LabelAnchorFlag.CENTER,
27
+ TOPRIGHT = LabelAnchorFlag.TOP | LabelAnchorFlag.RIGHT,
28
+ MIDDLELEFT = LabelAnchorFlag.MIDDLE | LabelAnchorFlag.LEFT,
29
+ MIDDLECENTER = LabelAnchorFlag.MIDDLE | LabelAnchorFlag.CENTER,
30
+ MIDDLERIGHT = LabelAnchorFlag.MIDDLE | LabelAnchorFlag.RIGHT,
31
+ BOTTOMLEFT = LabelAnchorFlag.BOTTOM | LabelAnchorFlag.LEFT,
32
+ BOTTOMCENTER = LabelAnchorFlag.BOTTOM | LabelAnchorFlag.CENTER,
33
+ BOTTOMRIGHT = LabelAnchorFlag.BOTTOM | LabelAnchorFlag.RIGHT
34
+ }
35
+
13
36
  /**
14
37
  * Class representing label style.
15
38
  * @ignore
@@ -65,15 +88,25 @@ export class NVLabel3D {
65
88
  text: string
66
89
  style: NVLabel3DStyle
67
90
  points?: number[] | number[][]
91
+ anchor: LabelAnchorPoint
92
+ onClick?: (label: NVLabel3D) => void
68
93
 
69
94
  /**
70
95
  * @param text - The text of the label
71
96
  * @param style - The style of the label
72
97
  * @param points - An array of points label for label lines
73
98
  */
74
- constructor(text: string, style: NVLabel3DStyle, points?: number[] | number[][]) {
99
+ constructor(
100
+ text: string,
101
+ style: NVLabel3DStyle,
102
+ points?: number[] | number[][],
103
+ anchor?: LabelAnchorPoint,
104
+ onClick?: (label: NVLabel3D) => void
105
+ ) {
75
106
  this.text = text
76
107
  this.style = style
77
108
  this.points = points
109
+ this.anchor = anchor || LabelAnchorPoint.NONE
110
+ this.onClick = onClick
78
111
  }
79
112
  }
@@ -709,6 +709,12 @@ export class NVMeshLoaders {
709
709
  while (lines[pos].length < 1) {
710
710
  pos++
711
711
  } // skip blank lines
712
+ if (lines[pos].startsWith('METADATA')) {
713
+ while (lines[pos].length > 1) {
714
+ pos++
715
+ } // skip until blank line
716
+ pos++
717
+ }
712
718
  items = lines[pos].trim().split(/\s+/)
713
719
  pos++
714
720
  if (items[0].includes('LINES')) {
@@ -909,7 +915,7 @@ export class NVMeshLoaders {
909
915
  layer.values = obj
910
916
  }
911
917
  }
912
- } else if (ext === 'CRV' || ext === 'CURV') {
918
+ } else if (ext === 'CRV' || ext === 'CURV' || ext === 'THICKNESS' || ext === 'AREA') {
913
919
  layer.values = NVMeshLoaders.readCURV(buffer, n_vert)
914
920
  layer.isTransparentBelowCalMin = false
915
921
  } else if (ext === 'GII') {
@@ -935,6 +941,10 @@ export class NVMeshLoaders {
935
941
  layer.values = NVMeshLoaders.readSMP(buffer, n_vert)
936
942
  } else if (ext === 'STC') {
937
943
  layer.values = NVMeshLoaders.readSTC(buffer, n_vert)
944
+ } else if (NVMeshLoaders.isCurv(buffer)) {
945
+ // Unknown layer overlay format - hail mary assume FreeSurfer
946
+ layer.values = NVMeshLoaders.readCURV(buffer, n_vert)
947
+ layer.isTransparentBelowCalMin = false
938
948
  } else {
939
949
  log.warn('Unknown layer overlay format ' + name)
940
950
  return layer
@@ -1123,6 +1133,19 @@ export class NVMeshLoaders {
1123
1133
  return f32
1124
1134
  } // readSTC()
1125
1135
 
1136
+ static isCurv(buffer: ArrayBuffer): boolean {
1137
+ const view = new DataView(buffer) // ArrayBuffer to dataview
1138
+ // ALWAYS big endian
1139
+ const sig0 = view.getUint8(0)
1140
+ const sig1 = view.getUint8(1)
1141
+ const sig2 = view.getUint8(2)
1142
+ if (sig0 !== 255 || sig1 !== 255 || sig2 !== 255) {
1143
+ utiltiesLogger.debug('Unable to recognize file type: does not appear to be FreeSurfer format.')
1144
+ return false
1145
+ }
1146
+ return true
1147
+ }
1148
+
1126
1149
  // read freesurfer curv big-endian format
1127
1150
  // https://github.com/bonilhamusclab/MRIcroS/blob/master/%2BfileUtils/%2Bpial/readPial.m
1128
1151
  // http://www.grahamwideman.com/gw/brain/fs/surfacefileformats.htm
@@ -1171,7 +1194,8 @@ export class NVMeshLoaders {
1171
1194
  const view = new DataView(buffer) // ArrayBuffer to dataview
1172
1195
  // ALWAYS big endian
1173
1196
  const n_vertex = view.getUint32(0, false)
1174
- if (n_vert !== n_vertex) {
1197
+ const n_vertexDecimated = this.decimateLayerVertices(n_vertex, n_vert)
1198
+ if (n_vert !== n_vertexDecimated) {
1175
1199
  throw new Error('ANNOT file has different number of vertices than mesh')
1176
1200
  }
1177
1201
  if (buffer.byteLength < 4 + 8 * n_vertex) {
@@ -2505,6 +2529,23 @@ export class NVMeshLoaders {
2505
2529
  }
2506
2530
  } // readSTL()
2507
2531
 
2532
+ static decimateLayerVertices(nVertLayer: number, nVertMesh: number): number {
2533
+ // downsample layer vertices if the mesh has been decimated
2534
+ if (nVertLayer % nVertMesh === 0) {
2535
+ return nVertLayer
2536
+ }
2537
+ const V0 = 12
2538
+ const orderLayer = Math.round(Math.log((nVertLayer - 2) / (V0 - 2)) / Math.log(4))
2539
+ const orderMesh = Math.round(Math.log((nVertMesh - 2) / (V0 - 2)) / Math.log(4))
2540
+ // sanity check
2541
+ const nVLayer = Math.pow(4, orderLayer) * (V0 - 2) + 2
2542
+ const nVMesh = Math.pow(4, orderMesh) * (V0 - 2) + 2
2543
+ if (nVLayer !== nVertLayer || nVMesh !== nVertMesh) {
2544
+ return nVertLayer
2545
+ }
2546
+ return nVertMesh
2547
+ }
2548
+
2508
2549
  // read NIfTI2 format with embedded CIfTI
2509
2550
  // this variation very specific to connectome workbench
2510
2551
  // https://brainder.org/2015/04/03/the-nifti-2-file-format/
@@ -2688,8 +2729,9 @@ export class NVMeshLoaders {
2688
2729
  //
2689
2730
  return scalars
2690
2731
  } // is CIfTI
2732
+ nvert = this.decimateLayerVertices(nvert, n_vert)
2691
2733
  if (nvert % n_vert !== 0) {
2692
- throw new Error('Vertices in NIfTI (' + nvert + ') is not a multiple of number of vertices (' + n_vert + ')')
2734
+ throw new Error('Vertices in layer (' + nvert + ') is not a multiple of number of vertices (' + n_vert + ')')
2693
2735
  }
2694
2736
  if (isLittleEndian) {
2695
2737
  // block read native endian
@@ -2776,8 +2818,9 @@ export class NVMeshLoaders {
2776
2818
  const dim = reader.getUint16(40 + i * 2, isLittleEndian)
2777
2819
  nvert *= Math.max(dim, 1)
2778
2820
  }
2821
+ nvert = this.decimateLayerVertices(nvert, n_vert)
2779
2822
  if (nvert % n_vert !== 0) {
2780
- throw new Error('Vertices in NIfTI (' + nvert + ') is not a multiple of number of vertices (' + n_vert + ')')
2823
+ throw new Error('Vertices in layer (' + nvert + ') is not a multiple of number of vertices (' + n_vert + ')')
2781
2824
  }
2782
2825
  if (isLittleEndian) {
2783
2826
  // block read native endian
@@ -2835,10 +2878,11 @@ export class NVMeshLoaders {
2835
2878
  if (version !== 1 || mtype < 0 || mtype > 4) {
2836
2879
  log.warn('Not a valid MGH file')
2837
2880
  }
2838
- const nvert = width * height * depth * nframes
2881
+ let nvert = width * height * depth * nframes
2839
2882
  let scalars: AnyNumberArray = []
2883
+ nvert = this.decimateLayerVertices(nvert, n_vert)
2840
2884
  if (nvert % n_vert !== 0) {
2841
- log.warn('Vertices in NIfTI (' + nvert + ') is not a multiple of number of vertices (' + n_vert + ')')
2885
+ log.warn('Vertices in layer (' + nvert + ') is not a multiple of number of vertices (' + n_vert + ')')
2842
2886
  return scalars
2843
2887
  }
2844
2888
  if (mtype === 3) {
@@ -1,4 +1,5 @@
1
1
  import { vec3 } from 'gl-matrix'
2
+ import { gzipSync } from 'fflate/browser'
2
3
 
3
4
  type Extents = {
4
5
  mxDx: number
@@ -38,6 +39,144 @@ export class NVMeshUtilities {
38
39
  return border
39
40
  }
40
41
 
42
+ static createMZ3(vertices: Float32Array, indices: Uint32Array, compress: boolean = false): ArrayBuffer {
43
+ // generate binary MZ3 format mesh
44
+ // n.b. small, precise and small but support is not widespread
45
+ // n.b. result can be compressed with gzip
46
+ // https://github.com/neurolabusc/surf-ice/tree/master/mz3
47
+ const magic = 23117
48
+ const attr = 3
49
+ const nface = indices.length / 3
50
+ const nvert = vertices.length / 3
51
+ const nskip = 0
52
+ // Calculate buffer size
53
+ const headerSize = 16
54
+ const indexSize = nface * 3 * 4 // Uint32Array
55
+ const vertexSize = nvert * 3 * 4 // Float32Array
56
+ const totalSize = headerSize + indexSize + vertexSize
57
+ const buffer = new ArrayBuffer(totalSize)
58
+ const writer = new DataView(buffer)
59
+ // Write header
60
+ writer.setUint16(0, magic, true)
61
+ writer.setUint16(2, attr, true)
62
+ writer.setUint32(4, nface, true)
63
+ writer.setUint32(8, nvert, true)
64
+ writer.setUint32(12, nskip, true)
65
+ // Write indices
66
+ let offset = headerSize
67
+ new Uint32Array(buffer, offset, indices.length).set(indices)
68
+ offset += indexSize
69
+ // Write vertices
70
+ new Float32Array(buffer, offset, vertices.length).set(vertices)
71
+ if (compress) {
72
+ return gzipSync(new Uint8Array(buffer))
73
+ }
74
+ return buffer
75
+ }
76
+
77
+ static createOBJ(vertices: Float32Array, indices: Uint32Array): ArrayBuffer {
78
+ // generate binary OBJ format mesh
79
+ // n.b. widespread support, but large and slow due to ASCII
80
+ // https://en.wikipedia.org/wiki/Wavefront_.obj_file
81
+ let objContent = ''
82
+ // Add vertices to OBJ content
83
+ for (let i = 0; i < vertices.length; i += 3) {
84
+ objContent += `v ${vertices[i]} ${vertices[i + 1]} ${vertices[i + 2]}\n`
85
+ }
86
+ // Add faces to OBJ content (OBJ indices start at 1, not 0)
87
+ for (let i = 0; i < indices.length; i += 3) {
88
+ objContent += `f ${indices[i] + 1} ${indices[i + 1] + 1} ${indices[i + 2] + 1}\n`
89
+ }
90
+ // Encode the OBJ content as an ArrayBuffer
91
+ const encoder = new TextEncoder()
92
+ const arrayBuffer = encoder.encode(objContent).buffer
93
+ return arrayBuffer
94
+ }
95
+
96
+ static createSTL(vertices: Float32Array, indices: Uint32Array): ArrayBuffer {
97
+ // generate binary STL format mesh
98
+ // n.b. inefficient and slow as vertices are not reused
99
+ // https://en.wikipedia.org/wiki/STL_(file_format)#Binary
100
+ const numTriangles = indices.length / 3
101
+ const bufferLength = 84 + numTriangles * 50
102
+ const arrayBuffer = new ArrayBuffer(bufferLength)
103
+ const dataView = new DataView(arrayBuffer)
104
+ // Write header (80 bytes)
105
+ for (let i = 0; i < 80; i++) {
106
+ dataView.setUint8(i, 0)
107
+ }
108
+ // Write number of triangles (4 bytes)
109
+ dataView.setUint32(80, numTriangles, true)
110
+ let offset = 84
111
+ for (let i = 0; i < indices.length; i += 3) {
112
+ const i0 = indices[i] * 3
113
+ const i1 = indices[i + 1] * 3
114
+ const i2 = indices[i + 2] * 3
115
+ // Normal vector (12 bytes, set to zero)
116
+ dataView.setFloat32(offset, 0, true) // Normal X
117
+ dataView.setFloat32(offset + 4, 0, true) // Normal Y
118
+ dataView.setFloat32(offset + 8, 0, true) // Normal Z
119
+ offset += 12
120
+ // Vertex 1 (12 bytes)
121
+ dataView.setFloat32(offset, vertices[i0], true) // Vertex 1 X
122
+ dataView.setFloat32(offset + 4, vertices[i0 + 1], true) // Vertex 1 Y
123
+ dataView.setFloat32(offset + 8, vertices[i0 + 2], true) // Vertex 1 Z
124
+ offset += 12
125
+ // Vertex 2 (12 bytes)
126
+ dataView.setFloat32(offset, vertices[i1], true) // Vertex 2 X
127
+ dataView.setFloat32(offset + 4, vertices[i1 + 1], true) // Vertex 2 Y
128
+ dataView.setFloat32(offset + 8, vertices[i1 + 2], true) // Vertex 2 Z
129
+ offset += 12
130
+ // Vertex 3 (12 bytes)
131
+ dataView.setFloat32(offset, vertices[i2], true) // Vertex 3 X
132
+ dataView.setFloat32(offset + 4, vertices[i2 + 1], true) // Vertex 3 Y
133
+ dataView.setFloat32(offset + 8, vertices[i2 + 2], true) // Vertex 3 Z
134
+ offset += 12
135
+ // Attribute byte count (2 bytes, set to zero)
136
+ dataView.setUint16(offset, 0, true)
137
+ offset += 2
138
+ }
139
+ return arrayBuffer
140
+ }
141
+
142
+ static downloadArrayBuffer(buffer: ArrayBuffer, filename: string): void {
143
+ const blob = new Blob([buffer], { type: 'application/octet-stream' })
144
+ const url = URL.createObjectURL(blob)
145
+ const a = document.createElement('a')
146
+ a.href = url
147
+ a.download = filename
148
+ document.body.appendChild(a)
149
+ a.style.display = 'none'
150
+ a.click()
151
+ setTimeout(() => {
152
+ document.body.removeChild(a)
153
+ URL.revokeObjectURL(url)
154
+ }, 0)
155
+ }
156
+
157
+ static saveMesh(
158
+ vertices: Float32Array,
159
+ indices: Uint32Array,
160
+ filename: string = '.mz3',
161
+ compress: boolean = false
162
+ ): ArrayBuffer {
163
+ let buff = new ArrayBuffer(0)
164
+ if (/\.obj$/i.test(filename)) {
165
+ buff = this.createOBJ(vertices, indices)
166
+ } else if (/\.stl$/i.test(filename)) {
167
+ buff = this.createSTL(vertices, indices)
168
+ } else {
169
+ if (!/\.mz3$/i.test(filename)) {
170
+ filename += '.mz3'
171
+ }
172
+ buff = this.createMZ3(vertices, indices, compress)
173
+ }
174
+ if (filename.length > 4) {
175
+ this.downloadArrayBuffer(buff, filename)
176
+ }
177
+ return buff
178
+ }
179
+
41
180
  static getClusterBoundary(rgba8: Uint8Array, faces: number[] | Uint32Array): boolean[] {
42
181
  const rgba32 = new Uint32Array(rgba8.buffer)
43
182
  // assume all vertices are not near a border