@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.
@@ -12,6 +12,7 @@ import {
12
12
  vertLineShader,
13
13
  vertLine3DShader,
14
14
  fragRectShader,
15
+ fragRectOutlineShader,
15
16
  vertRenderShader,
16
17
  fragRenderShader,
17
18
  fragRenderGradientShader,
@@ -41,6 +42,7 @@ import {
41
42
  fragMeshMatcapShader,
42
43
  fragMeshOutlineShader,
43
44
  fragMeshEdgeShader,
45
+ fragMeshShaderCrevice,
44
46
  fragMeshDiffuseEdgeShader,
45
47
  fragMeshHemiShader,
46
48
  fragMeshMatteShader,
@@ -78,7 +80,14 @@ import {
78
80
  INITIAL_SCENE_DATA
79
81
  } from '../nvdocument.js'
80
82
 
81
- import { LabelTextAlignment, LabelLineTerminator, NVLabel3D, NVLabel3DStyle } from '../nvlabel.js'
83
+ import {
84
+ LabelTextAlignment,
85
+ LabelLineTerminator,
86
+ NVLabel3D,
87
+ NVLabel3DStyle,
88
+ LabelAnchorPoint,
89
+ LabelAnchorFlag
90
+ } from '../nvlabel.js'
82
91
  import { FreeSurferConnectome, NVConnectome } from '../nvconnectome.js'
83
92
  import {
84
93
  NVImage,
@@ -96,7 +105,8 @@ import {
96
105
  NiftiHeader,
97
106
  DragReleaseParams,
98
107
  NiiVueLocation,
99
- NiiVueLocationValue
108
+ NiiVueLocationValue,
109
+ SyncOpts
100
110
  } from '../types.js'
101
111
  import {
102
112
  clamp,
@@ -121,8 +131,10 @@ export { NVImage, NVImageFromUrlOptions } from '../nvimage/index.js'
121
131
  // address rollup error - https://github.com/rollup/plugins/issues/71
122
132
  export * from '../nvdocument.js'
123
133
  export { NVUtilities } from '../nvutilities.js'
124
- export { LabelTextAlignment, LabelLineTerminator, NVLabel3DStyle, NVLabel3D } from '../nvlabel.js'
134
+ export { LabelTextAlignment, LabelLineTerminator, NVLabel3DStyle, NVLabel3D, LabelAnchorPoint } from '../nvlabel.js'
125
135
  export { NVMeshLoaders } from '../nvmesh-loaders.js'
136
+ export { NVMeshUtilities } from '../nvmesh-utilities.js'
137
+
126
138
  // same rollup error as above during npm run dev, and during the umd build
127
139
  // TODO: at least remove the umd build when AFNI do not need it anymore
128
140
  export * from '../types.js'
@@ -184,6 +196,7 @@ type Descriptive = {
184
196
  cal_max: number
185
197
  robust_min: number
186
198
  robust_max: number
199
+ area: number | null
187
200
  }
188
201
 
189
202
  type SliceScale = {
@@ -345,6 +358,7 @@ export class Niivue {
345
358
  orientCubeShader?: Shader
346
359
  orientCubeShaderVAO: WebGLVertexArrayObject | null = null
347
360
  rectShader?: Shader
361
+ rectOutlineShader?: Shader
348
362
  renderShader?: Shader
349
363
  lineShader?: Shader
350
364
  line3DShader?: Shader
@@ -390,7 +404,19 @@ export class Niivue {
390
404
  extentsMax?: vec3
391
405
  // ResizeObserver
392
406
  private resizeObserver: ResizeObserver | null = null
393
- syncOpts: Record<string, unknown> = {}
407
+ // syncOpts: Record<string, unknown> = {}
408
+ syncOpts: SyncOpts = {
409
+ '3d': false, // legacy option
410
+ '2d': false, // legacy option
411
+ zoomPan: false,
412
+ cal_min: false,
413
+ cal_max: false,
414
+ clipPlane: false,
415
+ gamma: false,
416
+ sliceType: false,
417
+ crosshair: false
418
+ }
419
+
394
420
  readyForSync = false
395
421
 
396
422
  // UI Data
@@ -440,7 +466,7 @@ export class Niivue {
440
466
 
441
467
  cuboidVertexBuffer?: WebGLBuffer
442
468
 
443
- otherNV: Niivue | Niivue[] | null = null // another niivue instance that we wish to sync position with
469
+ otherNV: Niivue[] | null = null // another niivue instance that we wish to sync position with
444
470
  volumeObject3D: NiivueObject3D | null = null
445
471
  pivot3D = [0, 0, 0] // center for rendering rotation
446
472
  furthestFromPivot = 10.0 // most distant point from pivot
@@ -477,6 +503,10 @@ export class Niivue {
477
503
  Name: 'Hemispheric',
478
504
  Frag: fragMeshHemiShader
479
505
  },
506
+ {
507
+ Name: 'Crevice',
508
+ Frag: fragMeshShaderCrevice
509
+ },
480
510
  {
481
511
  Name: 'Edge',
482
512
  Frag: fragMeshEdgeShader
@@ -905,7 +935,11 @@ export class Niivue {
905
935
  * @deprecated use broadcastTo instead
906
936
  * @see {@link https://niivue.github.io/niivue/features/sync.mesh.html | live demo usage}
907
937
  */
908
- syncWith(otherNV: Niivue, syncOpts = { '2d': true, '3d': true }): void {
938
+ syncWith(otherNV: Niivue | Niivue[], syncOpts = { '2d': true, '3d': true }): void {
939
+ // if otherNV is not an array, make it an array of one
940
+ if (!(otherNV instanceof Array)) {
941
+ otherNV = [otherNV]
942
+ }
909
943
  this.otherNV = otherNV
910
944
  this.syncOpts = { ...syncOpts }
911
945
  }
@@ -922,10 +956,71 @@ export class Niivue {
922
956
  * @see {@link https://niivue.github.io/niivue/features/sync.mesh.html | live demo usage}
923
957
  */
924
958
  broadcastTo(otherNV: Niivue | Niivue[], syncOpts = { '2d': true, '3d': true }): void {
959
+ // if otherNV is a single instance then make it an array of one
960
+ if (!(otherNV instanceof Array)) {
961
+ otherNV = [otherNV]
962
+ }
925
963
  this.otherNV = otherNV
926
964
  this.syncOpts = syncOpts
927
965
  }
928
966
 
967
+ doSync3d(otherNV: Niivue): void {
968
+ otherNV.scene.renderAzimuth = this.scene.renderAzimuth
969
+ otherNV.scene.renderElevation = this.scene.renderElevation
970
+ otherNV.scene.volScaleMultiplier = this.scene.volScaleMultiplier
971
+ }
972
+
973
+ // both crosshair and zoomPan
974
+ doSync2d(otherNV: Niivue): void {
975
+ const thisMM = this.frac2mm(this.scene.crosshairPos)
976
+ otherNV.scene.crosshairPos = otherNV.mm2frac(thisMM)
977
+ otherNV.scene.pan2Dxyzmm = vec4.clone(this.scene.pan2Dxyzmm)
978
+ }
979
+
980
+ doSyncGamma(otherNV: Niivue): void {
981
+ // gamma not dependent on 2d/3d
982
+ const thisGamma = this.scene.gamma
983
+ const otherGamma = otherNV.scene.gamma
984
+ if (thisGamma !== otherGamma) {
985
+ otherNV.setGamma(thisGamma)
986
+ }
987
+ }
988
+
989
+ doSyncZoomPan(otherNV: Niivue): void {
990
+ otherNV.scene.pan2Dxyzmm = vec4.clone(this.scene.pan2Dxyzmm)
991
+ }
992
+
993
+ doSyncCrosshair(otherNV: Niivue): void {
994
+ const thisMM = this.frac2mm(this.scene.crosshairPos)
995
+ otherNV.scene.crosshairPos = otherNV.mm2frac(thisMM)
996
+ }
997
+
998
+ doSyncCalMin(otherNV: Niivue): void {
999
+ // only call updateGLVolume if the cal_min is different
1000
+ // because updateGLVolume is expensive, but required to update the volume
1001
+ if (this.volumes[0].cal_min !== otherNV.volumes[0].cal_min) {
1002
+ otherNV.volumes[0].cal_min = this.volumes[0].cal_min
1003
+ otherNV.updateGLVolume()
1004
+ }
1005
+ }
1006
+
1007
+ doSyncCalMax(otherNV: Niivue): void {
1008
+ // only call updateGLVolume if the cal_max is different
1009
+ // because updateGLVolume is expensive, but required to update the volume
1010
+ if (this.volumes[0].cal_max !== otherNV.volumes[0].cal_max) {
1011
+ otherNV.volumes[0].cal_max = this.volumes[0].cal_max
1012
+ otherNV.updateGLVolume()
1013
+ }
1014
+ }
1015
+
1016
+ doSyncSliceType(otherNV: Niivue): void {
1017
+ otherNV.setSliceType(this.opts.sliceType)
1018
+ }
1019
+
1020
+ doSyncClipPlane(otherNV: Niivue): void {
1021
+ otherNV.setClipPlane(this.scene.clipPlaneDepthAziElev)
1022
+ }
1023
+
929
1024
  /**
930
1025
  * Sync the scene controls (orientation, crosshair location, etc.) from one Niivue instance to another. useful for using one canvas to drive another.
931
1026
  * @internal
@@ -939,44 +1034,52 @@ export class Niivue {
939
1034
  if (!this.gl || !this.otherNV || typeof this.otherNV === 'undefined') {
940
1035
  return
941
1036
  }
942
- // if (!this.otherNV.readyForSync || !this.readyForSync) {
943
- // return;
944
- // }
945
1037
  // canvas must have focus to send messages issue706
946
1038
  if (!(this.gl.canvas as HTMLCanvasElement).matches(':focus')) {
947
1039
  return
948
1040
  }
949
- const thisMM = this.frac2mm(this.scene.crosshairPos)
950
- // if this.otherNV is an object, then it is a single Niivue instance
951
- if (this.otherNV instanceof Niivue) {
1041
+ for (let i = 0; i < this.otherNV.length; i++) {
1042
+ if (this.otherNV[i] === this) {
1043
+ continue
1044
+ }
1045
+ // gamma
1046
+ if (this.syncOpts.gamma) {
1047
+ this.doSyncGamma(this.otherNV[i])
1048
+ }
1049
+ // crosshair
1050
+ if (this.syncOpts.crosshair) {
1051
+ this.doSyncCrosshair(this.otherNV[i])
1052
+ }
1053
+ // zoomPan
1054
+ if (this.syncOpts.zoomPan) {
1055
+ this.doSyncZoomPan(this.otherNV[i])
1056
+ }
1057
+ // sliceType
1058
+ if (this.syncOpts.sliceType) {
1059
+ this.doSyncSliceType(this.otherNV[i])
1060
+ }
1061
+ // cal_min
1062
+ if (this.syncOpts.cal_min) {
1063
+ this.doSyncCalMin(this.otherNV[i])
1064
+ }
1065
+ // cal_max
1066
+ if (this.syncOpts.cal_max) {
1067
+ this.doSyncCalMax(this.otherNV[i])
1068
+ }
1069
+ // clipPlane
1070
+ if (this.syncOpts.clipPlane) {
1071
+ this.doSyncClipPlane(this.otherNV[i])
1072
+ }
1073
+ // legacy 2d option for multiple properties
952
1074
  if (this.syncOpts['2d']) {
953
- this.otherNV.scene.crosshairPos = this.otherNV.mm2frac(thisMM)
954
- this.otherNV.scene.pan2Dxyzmm = vec4.clone(this.scene.pan2Dxyzmm)
1075
+ this.doSync2d(this.otherNV[i])
955
1076
  }
1077
+ // legacy 3d option for multiple properties
956
1078
  if (this.syncOpts['3d']) {
957
- this.otherNV.scene.renderAzimuth = this.scene.renderAzimuth
958
- this.otherNV.scene.renderElevation = this.scene.renderElevation
959
- this.otherNV.scene.volScaleMultiplier = this.scene.volScaleMultiplier
960
- }
961
- this.otherNV.drawScene()
962
- this.otherNV.createOnLocationChange()
963
- } else if (Array.isArray(this.otherNV)) {
964
- for (let i = 0; i < this.otherNV.length; i++) {
965
- if (this.otherNV[i] === this) {
966
- continue
967
- }
968
- if (this.syncOpts['2d']) {
969
- this.otherNV[i].scene.crosshairPos = this.otherNV[i].mm2frac(thisMM)
970
- this.otherNV[i].scene.pan2Dxyzmm = vec4.clone(this.scene.pan2Dxyzmm)
971
- }
972
- if (this.syncOpts['3d']) {
973
- this.otherNV[i].scene.renderAzimuth = this.scene.renderAzimuth
974
- this.otherNV[i].scene.renderElevation = this.scene.renderElevation
975
- this.otherNV[i].scene.volScaleMultiplier = this.scene.volScaleMultiplier
976
- }
977
- this.otherNV[i].drawScene()
978
- this.otherNV[i].createOnLocationChange()
1079
+ this.doSync3d(this.otherNV[i])
979
1080
  }
1081
+ this.otherNV[i].drawScene()
1082
+ this.otherNV[i].createOnLocationChange()
980
1083
  }
981
1084
  }
982
1085
 
@@ -1097,6 +1200,11 @@ export class Niivue {
1097
1200
  const [x, y] = [pos.x * this.uiData.dpr!, pos.y * this.uiData.dpr!]
1098
1201
  const label = this.getLabelAtPoint([x, y])
1099
1202
  if (label) {
1203
+ // check for user defined onclick handler
1204
+ if (label.onClick) {
1205
+ label.onClick(label)
1206
+ return
1207
+ }
1100
1208
  // find associated mesh
1101
1209
  for (const mesh of this.meshes) {
1102
1210
  if (mesh.type !== MeshType.CONNECTOME) {
@@ -1273,7 +1381,6 @@ export class Niivue {
1273
1381
  const mmLength = vec3.len(v)
1274
1382
  const voxStart = this.frac2vox(fracStart)
1275
1383
  const voxEnd = this.frac2vox(fracEnd)
1276
-
1277
1384
  this.onDragRelease({
1278
1385
  fracStart,
1279
1386
  fracEnd,
@@ -1326,6 +1433,11 @@ export class Niivue {
1326
1433
  const fracStart = this.canvasPos2frac([this.uiData.dragStart[0], this.uiData.dragStart[1]])
1327
1434
  const fracEnd = this.canvasPos2frac([this.uiData.dragEnd[0], this.uiData.dragEnd[1]])
1328
1435
  this.generateMouseUpCallback(fracStart, fracEnd)
1436
+ // if roiSelection drag mode
1437
+ if (this.opts.dragMode === DRAG_MODE.roiSelection) {
1438
+ // do not call drawScene so that the selection box remains visible
1439
+ return
1440
+ }
1329
1441
  if (this.opts.dragMode !== DRAG_MODE.contrast) {
1330
1442
  return
1331
1443
  }
@@ -1445,6 +1557,16 @@ export class Niivue {
1445
1557
  this.drawScene()
1446
1558
  this.uiData.prevX = this.uiData.currX
1447
1559
  this.uiData.prevY = this.uiData.currY
1560
+ } else if (!this.uiData.mousedown && this.opts.clickToSegment) {
1561
+ const pos = this.getNoPaddingNoBorderCanvasRelativeMousePosition(e, this.gl.canvas)
1562
+ // ignore if mouse moves outside of tile of initial click
1563
+ if (!pos) {
1564
+ return
1565
+ }
1566
+ const x = pos.x * this.uiData.dpr!
1567
+ const y = pos.y * this.uiData.dpr!
1568
+ this.mousePos = [x, y]
1569
+ this.drawScene()
1448
1570
  }
1449
1571
  }
1450
1572
 
@@ -2049,7 +2171,7 @@ export class Niivue {
2049
2171
  this.addVolume(volume)
2050
2172
  // set drawing enabled to make sure
2051
2173
  // the new drawing bitmap matches the background volume dims
2052
- this.setDrawingEnabled(true)
2174
+ // this.setDrawingEnabled(true)
2053
2175
  }
2054
2176
  })
2055
2177
  .catch((e) => {
@@ -2807,6 +2929,25 @@ export class Niivue {
2807
2929
  return this.meshes[idx].indexNearestXYZmm(Xmm, Ymm, Zmm)
2808
2930
  }
2809
2931
 
2932
+ /**
2933
+ * reduce complexity of FreeSurfer mesh
2934
+ * @param mesh - identity of mesh to change
2935
+ * @param order - decimation order 0..6
2936
+ * @example niivue.decimateHierarchicalMesh(niivue.meshes[0].id, 4)
2937
+ * @returns boolean false if mesh is not hierarchical or of lower order
2938
+ * @see {@link https://niivue.github.io/niivue/features/meshes.html | live demo usage}
2939
+ */
2940
+ decimateHierarchicalMesh(mesh: number, order: number = 3): boolean {
2941
+ const idx = this.getMeshIndexByID(mesh)
2942
+ if (idx < 0) {
2943
+ log.warn('reverseFaces() id not loaded', mesh)
2944
+ return
2945
+ }
2946
+ const ret = this.meshes[idx].decimateHierarchicalMesh(this.gl, order)
2947
+ this.updateGLVolume()
2948
+ return ret
2949
+ }
2950
+
2810
2951
  /**
2811
2952
  * reverse triangle winding of mesh (swap front and back faces)
2812
2953
  * @param id - identity of mesh to change
@@ -3366,6 +3507,9 @@ export class Niivue {
3366
3507
  this.renderShader!.use(this.gl)
3367
3508
  this.gl.uniform3fv(this.renderShader!.uniforms.clipLo!, this.opts.clipVolumeLow)
3368
3509
  this.gl.uniform3fv(this.renderShader!.uniforms.clipHi!, this.opts.clipVolumeHigh)
3510
+ this.pickingImageShader!.use(this.gl)
3511
+ this.gl.uniform3fv(this.pickingImageShader!.uniforms.clipLo!, this.opts.clipVolumeLow)
3512
+ this.gl.uniform3fv(this.pickingImageShader!.uniforms.clipHi!, this.opts.clipVolumeHigh)
3369
3513
  this.drawScene()
3370
3514
  }
3371
3515
 
@@ -4169,6 +4313,29 @@ export class Niivue {
4169
4313
  y = Math.min(Math.max(y, 0), dy - 1)
4170
4314
  z = Math.min(Math.max(z, 0), dz - 1)
4171
4315
  this.drawBitmap![x + y * dx + z * dx * dy] = penValue
4316
+ // get tile index for voxel
4317
+ const isAx = this.drawPenAxCorSag === 0
4318
+ const isCor = this.drawPenAxCorSag === 1
4319
+ const isSag = this.drawPenAxCorSag === 2
4320
+ // since the pen is only drawing in one 2D plane,
4321
+ // only draw the neighbors (based on penSize) in that plane.
4322
+ // if penSize is 1, only draw the voxel itself.
4323
+ // if penSize is even (2, 4, 6, etc.), then the extra voxel will be drawn in the positive direction.
4324
+ // if penSize is odd (3, 5, 7, etc.), then the the pen will be centered on the voxel.
4325
+ if (this.opts.penSize > 1) {
4326
+ const halfPenSize = Math.floor(this.opts.penSize / 2)
4327
+ for (let i = -halfPenSize; i <= halfPenSize; i++) {
4328
+ for (let j = -halfPenSize; j <= halfPenSize; j++) {
4329
+ if (isAx) {
4330
+ this.drawBitmap![x + i + (y + j) * dx + z * dx * dy] = penValue
4331
+ } else if (isCor) {
4332
+ this.drawBitmap![x + i + y * dx + (z + j) * dx * dy] = penValue
4333
+ } else if (isSag) {
4334
+ this.drawBitmap![x + (y + j) * dx + (z + i) * dx * dy] = penValue
4335
+ }
4336
+ }
4337
+ }
4338
+ }
4172
4339
  }
4173
4340
 
4174
4341
  // not included in public docs
@@ -4255,6 +4422,140 @@ export class Niivue {
4255
4422
  }
4256
4423
  }
4257
4424
 
4425
+ /**
4426
+ * Performs a 1-voxel binary dilation on a connected cluster within the drawing mask using the drawFloodFillCore function.
4427
+ *
4428
+ * @param seedXYZ - voxel index of the seed voxel in the mask array.
4429
+ * @param neighbors - Number of neighbors to consider for connectivity and dilation (6, 18, or 26).
4430
+ */
4431
+ drawingBinaryDilationWithSeed(
4432
+ seedXYZ: number[], // seed voxel x,y,z
4433
+ neighbors: 6 | 18 | 26 = 6
4434
+ ): void {
4435
+ try {
4436
+ const mask = this.drawBitmap
4437
+ const xDim = this.back.dims[1]
4438
+ const yDim = this.back.dims[2]
4439
+ const zDim = this.back.dims[3]
4440
+ const nx = xDim
4441
+ const nxy = xDim * yDim
4442
+ const totalVoxels = nxy * zDim
4443
+ function xyz2vx(pt: number[]): number {
4444
+ return pt[0] + pt[1] * nx + pt[2] * nxy
4445
+ }
4446
+
4447
+ const seedIndex = xyz2vx(seedXYZ)
4448
+
4449
+ // check that the seed index is within bounds
4450
+ if (seedIndex < 0 || seedIndex >= totalVoxels) {
4451
+ throw new Error('Seed index is out of bounds.')
4452
+ }
4453
+
4454
+ // get value of the seed voxel
4455
+ const seedValue = mask[seedIndex]
4456
+
4457
+ // check that the seed voxel is filled
4458
+ if (seedValue === 0) {
4459
+ throw new Error('Seed voxel is not part of a filled cluster.')
4460
+ }
4461
+
4462
+ // create a copy of the mask to work on
4463
+ const img = mask.slice()
4464
+ // binarise the img since there could be multiple colors in the mask
4465
+ for (let i = 0; i < totalVoxels; i++) {
4466
+ img[i] = img[i] === seedValue ? 1 : 0
4467
+ }
4468
+
4469
+ // use drawFloodFillCore to identify the connected cluster starting from seedIndex
4470
+ this.drawFloodFillCore(img, seedIndex, neighbors)
4471
+
4472
+ // now, img has the cluster marked with value 2
4473
+ // create an output mask for dilation
4474
+ const outputMask = mask.slice() // Clone the original mask
4475
+
4476
+ // precompute neighbor offsets based on connectivity
4477
+ const neighborOffsets: number[] = []
4478
+
4479
+ // offsets for 6-connectivity (face neighbors)
4480
+ const offsets6 = [-nxy, nxy, -xDim, xDim, -1, 1]
4481
+
4482
+ neighborOffsets.push(...offsets6)
4483
+
4484
+ if (neighbors > 6) {
4485
+ // offsets for 18-connectivity (edge neighbors)
4486
+ neighborOffsets.push(
4487
+ -xDim - 1,
4488
+ -xDim + 1,
4489
+ xDim - 1,
4490
+ xDim + 1,
4491
+ -nxy - xDim,
4492
+ -nxy + xDim,
4493
+ -nxy - 1,
4494
+ -nxy + 1,
4495
+ nxy - xDim,
4496
+ nxy + xDim,
4497
+ nxy - 1,
4498
+ nxy + 1
4499
+ )
4500
+ }
4501
+
4502
+ if (neighbors > 18) {
4503
+ // offsets for 26-connectivity (corner neighbors)
4504
+ neighborOffsets.push(
4505
+ -nxy - xDim - 1,
4506
+ -nxy - xDim + 1,
4507
+ -nxy + xDim - 1,
4508
+ -nxy + xDim + 1,
4509
+ nxy - xDim - 1,
4510
+ nxy - xDim + 1,
4511
+ nxy + xDim - 1,
4512
+ nxy + xDim + 1
4513
+ )
4514
+ }
4515
+
4516
+ // iterate over the cluster voxels (value 2 in img) to perform dilation
4517
+ for (let idx = 0; idx < totalVoxels; idx++) {
4518
+ if (img[idx] === 2) {
4519
+ const x = idx % xDim
4520
+ const y = Math.floor((idx % nxy) / xDim)
4521
+ const z = Math.floor(idx / nxy)
4522
+
4523
+ for (const offset of neighborOffsets) {
4524
+ const neighborIdx = idx + offset
4525
+
4526
+ // skip if neighbor index is out of bounds
4527
+ if (neighborIdx < 0 || neighborIdx >= totalVoxels) {
4528
+ continue
4529
+ }
4530
+
4531
+ // calculate neighbor coordinates
4532
+ const nx = neighborIdx % xDim
4533
+ const ny = Math.floor((neighborIdx % nxy) / xDim)
4534
+ const nz = Math.floor(neighborIdx / nxy)
4535
+
4536
+ // ensure neighbor is adjacent (prevent wrapping around edges)
4537
+ if (Math.abs(nx - x) > 1 || Math.abs(ny - y) > 1 || Math.abs(nz - z) > 1) {
4538
+ continue
4539
+ }
4540
+
4541
+ // if the neighbor voxel is empty in the original mask, fill it in the output mask
4542
+ if (mask[neighborIdx] === 0) {
4543
+ outputMask[neighborIdx] = seedValue
4544
+ }
4545
+ }
4546
+ }
4547
+ }
4548
+ // set the output as the new drawing bitmap
4549
+ this.drawBitmap = outputMask
4550
+ // update the undo stack
4551
+ this.drawAddUndoBitmap()
4552
+ // refresh the drawing (copy from cpu to gpu) and show immediately
4553
+ this.refreshDrawing(true)
4554
+ } catch (error) {
4555
+ log.error('Error in drawingBinaryDilationWithSeed:', error)
4556
+ }
4557
+ }
4558
+
4258
4559
  // a voxel can be defined as having 6, 18 or 26 neighbors:
4259
4560
  // 6: neighbors share faces (distance=1)
4260
4561
  // 18: neighbors share faces (distance=1) or edges (1.4)
@@ -4357,7 +4658,9 @@ export class Niivue {
4357
4658
  growSelectedCluster = 0, // if non-zero, growth based on background intensity POSITIVE_INFINITY for selected or bright, NEGATIVE_INFINITY for selected or darker
4358
4659
  forceMin = NaN,
4359
4660
  forceMax = NaN,
4360
- neighbors = 6
4661
+ neighbors = 6,
4662
+ // option for only flood filling within max distance from seed voxel
4663
+ maxDistanceMM = Number.POSITIVE_INFINITY
4361
4664
  ): void {
4362
4665
  if (!this.drawBitmap) {
4363
4666
  throw new Error('drawBitmap undefined')
@@ -4383,10 +4686,29 @@ export class Niivue {
4383
4686
  if (img.length !== nxy * dims[2]) {
4384
4687
  return
4385
4688
  }
4689
+ function vx2xyz(vx: number): number[] {
4690
+ // provided address in 1D array, return XYZ coordinate
4691
+ const Z = Math.floor(vx / nxy) // slice
4692
+ const Y = Math.floor((vx - Z * nxy) / nx) // column
4693
+ const X = Math.floor(vx % nx)
4694
+ return [X, Y, Z]
4695
+ }
4386
4696
  function xyz2vx(pt: number[]): number {
4387
4697
  // provided an XYZ 3D point, provide address in 1D array
4388
4698
  return pt[0] + pt[1] * nx + pt[2] * nxy
4389
4699
  }
4700
+ const vx2mm = (xyz: number[]): vec3 => {
4701
+ return this.vox2mm(xyz, this.back.matRAS)
4702
+ }
4703
+ // store seed vox as mm coordinates
4704
+ const seedMM = vx2mm(seedXYZ)
4705
+ const maxDistanceMM2 = maxDistanceMM ** 2
4706
+ // function to check if new point to be checked is less than maxDistanceMM
4707
+ function isWithinDistance(vx: number): boolean {
4708
+ const xyzMM = vx2mm(vx2xyz(vx))
4709
+ const dist2 = (xyzMM[0] - seedMM[0]) ** 2 + (xyzMM[1] - seedMM[1]) ** 2 + (xyzMM[2] - seedMM[2]) ** 2
4710
+ return dist2 <= maxDistanceMM2
4711
+ }
4390
4712
  const seedVx = xyz2vx(seedXYZ)
4391
4713
  const seedColor = img[seedVx]
4392
4714
  if (seedColor === newColor) {
@@ -4400,6 +4722,12 @@ export class Niivue {
4400
4722
  for (let i = 1; i < nxyz; i++) {
4401
4723
  img[i] = 0
4402
4724
  if (this.drawBitmap[i] === seedColor) {
4725
+ // check if voxel index i is within maxDistanceMM from seed voxel
4726
+ if (!isWithinDistance(i)) {
4727
+ // move to next voxel if not within distance,
4728
+ // no need to check voxel intensity for cluster assignment
4729
+ continue
4730
+ }
4403
4731
  img[i] = 1
4404
4732
  }
4405
4733
  }
@@ -4431,6 +4759,12 @@ export class Niivue {
4431
4759
  for (let i = 1; i < nxyz; i++) {
4432
4760
  img[i] = 0
4433
4761
  if (backImg[i] >= mn && backImg[i] <= mx) {
4762
+ // check if voxel index i is within maxDistanceMM from seed voxel
4763
+ if (!isWithinDistance(i)) {
4764
+ // move to next voxel if not within distance,
4765
+ // no need to check voxel intensity for cluster assignment
4766
+ continue
4767
+ }
4434
4768
  img[i] = 1
4435
4769
  }
4436
4770
  }
@@ -5139,6 +5473,8 @@ export class Niivue {
5139
5473
  // rect shader (crosshair): horizontal and vertical lines only
5140
5474
  this.rectShader = new Shader(gl, vertRectShader, fragRectShader)
5141
5475
  this.rectShader.use(gl)
5476
+ this.rectOutlineShader = new Shader(gl, vertRectShader, fragRectOutlineShader)
5477
+ this.rectOutlineShader.use(gl)
5142
5478
  // line shader: diagonal lines
5143
5479
  this.lineShader = new Shader(gl, vertLineShader, fragRectShader)
5144
5480
  this.lineShader.use(gl)
@@ -5314,14 +5650,46 @@ export class Niivue {
5314
5650
 
5315
5651
  /**
5316
5652
  * basic statistics for selected voxel-based image
5317
- * @param layer - selects image to describe
5318
- * @param masks - are optional binary images to filter voxles
5319
- * @returns numeric values to describe image
5320
- * @example niivue.getDescriptives(0);
5653
+ * @param options - an object containing the following properties:
5654
+ * - layer: selects image to describe
5655
+ * - masks: optional binary images to filter voxels
5656
+ * - drawingIsMask: a boolean indicating if the drawing is used as a mask
5657
+ * - roiIsMask: a boolean indicating if the ROI is used as a mask
5658
+ * - startVox: the starting voxel coordinates
5659
+ * - endVox: the ending voxel coordinates
5660
+ * @returns numeric values to describe image or regions of images
5661
+ * @example
5662
+ * niivue.getDescriptives({
5663
+ * layer: 0,
5664
+ * masks: [],
5665
+ * drawingIsMask: true, // drawingIsMask and roiIsMask are mutually exclusive
5666
+ * roiIsMask: false,
5667
+ * startVox: [10, 20, 30], // ignored if roiIsMask is false
5668
+ * endVox: [40, 50, 60] // ignored if roiIsMask is false
5669
+ * });
5321
5670
  * @see {@link https://niivue.github.io/niivue/features/draw2.html | live demo usage}
5322
5671
  */
5323
- getDescriptives(layer = 0, masks = [], drawingIsMask = false): Descriptive {
5672
+ getDescriptives(options: {
5673
+ layer?: number
5674
+ masks?: number[]
5675
+ drawingIsMask?: boolean
5676
+ roiIsMask?: boolean
5677
+ startVox?: number[]
5678
+ endVox?: number[]
5679
+ }): Descriptive {
5680
+ const {
5681
+ layer = 0,
5682
+ masks = [],
5683
+ drawingIsMask = false,
5684
+ roiIsMask = false,
5685
+ startVox = [0, 0, 0],
5686
+ endVox = [0, 0, 0]
5687
+ } = options
5688
+
5689
+ // Rest of the code remains the same
5690
+ let area = null // used if roiIsMask since ROI is in 2D slice
5324
5691
  const hdr = this.volumes[layer].hdr!
5692
+ const pixDimsRAS = this.volumes[layer].pixDimsRAS!
5325
5693
  let slope = hdr.scl_slope
5326
5694
  if (isNaN(slope)) {
5327
5695
  slope = 1
@@ -5360,6 +5728,48 @@ export class Niivue {
5360
5728
  mask[i] = 0
5361
5729
  }
5362
5730
  }
5731
+ } else if (masks.length < 1 && roiIsMask) {
5732
+ // fill mask with zeros
5733
+ mask.fill(0)
5734
+ console.log('startVox', startVox)
5735
+ console.log('endVox', endVox)
5736
+ // const startVox = roiInfo.startVox
5737
+ // const endVox = roiInfo.endVox
5738
+ const xrange = this.calculateMinMaxVoxIdx([startVox[0], endVox[0]])
5739
+ const yrange = this.calculateMinMaxVoxIdx([startVox[1], endVox[1]])
5740
+ const zrange = this.calculateMinMaxVoxIdx([startVox[2], endVox[2]])
5741
+
5742
+ // for our constant dimension we add one so that the for loop runs at least once
5743
+ if (startVox[0] - endVox[0] === 0) {
5744
+ xrange[1] = startVox[0] + 1
5745
+ } else if (startVox[1] - endVox[1] === 0) {
5746
+ yrange[1] = startVox[1] + 1
5747
+ } else if (startVox[2] - endVox[2] === 0) {
5748
+ zrange[1] = startVox[2] + 1
5749
+ }
5750
+
5751
+ const xdim = hdr.dims[1]
5752
+ const ydim = hdr.dims[2]
5753
+ for (let z = zrange[0]; z < zrange[1]; z++) {
5754
+ const zi = z * xdim * ydim
5755
+ for (let y = yrange[0]; y < yrange[1]; y++) {
5756
+ const yi = y * xdim
5757
+ for (let x = xrange[0]; x < xrange[1]; x++) {
5758
+ const index = zi + yi + x
5759
+ mask[index] = 1
5760
+ }
5761
+ }
5762
+ }
5763
+ // calculate area 2d area from startVox and endVox coordinates if roiIsMask is true (since mask is created from a 2d slice only)
5764
+ // area is in mm^2. The below calculations work regardless of the orientation of the image since
5765
+ // Niivue converts all images to RAS
5766
+ const xmm = Math.abs(pixDimsRAS[1] * (endVox[0] - startVox[0]))
5767
+ const ymm = Math.abs(pixDimsRAS[2] * (endVox[1] - startVox[1]))
5768
+ const zmm = Math.abs(pixDimsRAS[3] * (endVox[2] - startVox[2]))
5769
+ // one of the above will be zero since roiIsMask==true, so we filter out the non-zero value
5770
+ // and then multiply the other two to get the area
5771
+ const mms = [xmm, ymm, zmm]
5772
+ area = mms.filter((m) => m !== 0).reduce((a, b) => a * b, 1)
5363
5773
  }
5364
5774
  // Welfords method
5365
5775
  // https://www.embeddedrelated.com/showarticle/785.php
@@ -5420,7 +5830,8 @@ export class Niivue {
5420
5830
  cal_min: this.volumes[layer].cal_min!,
5421
5831
  cal_max: this.volumes[layer].cal_max!,
5422
5832
  robust_min: this.volumes[layer].robust_min!,
5423
- robust_max: this.volumes[layer].robust_max!
5833
+ robust_max: this.volumes[layer].robust_max!,
5834
+ area
5424
5835
  }
5425
5836
  }
5426
5837
 
@@ -5947,7 +6358,8 @@ export class Niivue {
5947
6358
  this.pickingImageShader.use(this.gl)
5948
6359
  this.gl.uniform1f(this.pickingImageShader.uniforms.overlays, this.overlays.length)
5949
6360
  this.gl.uniform3fv(this.pickingImageShader.uniforms.texVox, vox)
5950
-
6361
+ this.gl.uniform3fv(this.pickingImageShader!.uniforms.clipLo!, this.opts.clipVolumeLow)
6362
+ this.gl.uniform3fv(this.pickingImageShader!.uniforms.clipHi!, this.opts.clipVolumeHigh)
5951
6363
  let shader = this.sliceMMShader
5952
6364
  if (this.opts.isV1SliceShader) {
5953
6365
  shader = this.sliceV1Shader
@@ -6771,6 +7183,7 @@ export class Niivue {
6771
7183
  * @see {@link https://niivue.github.io/niivue/features/colormaps.html | live demo usage}
6772
7184
  */
6773
7185
  setGamma(gamma = 1.0): void {
7186
+ this.scene.gamma = gamma
6774
7187
  cmapper.gamma = gamma
6775
7188
  this.updateGLVolume()
6776
7189
  }
@@ -7182,34 +7595,72 @@ export class Niivue {
7182
7595
  this.scene.crosshairPos = vec3.clone(texFrac)
7183
7596
  }
7184
7597
  if (this.opts.drawingEnabled) {
7598
+ // drawing done in voxels
7185
7599
  const pt = this.frac2vox(this.scene.crosshairPos) as [number, number, number]
7186
7600
  // if click-to-segment enabled
7187
7601
  if (this.opts.clickToSegment) {
7188
- const radius = this.opts.clickToSegmentRadius
7189
- const steps = this.opts.clickToSegmentSteps
7602
+ // get voxel value of pt
7603
+ const voxelIntensity = this.back.getValue(pt[0], pt[1], pt[2])
7604
+ if (this.opts.clickToSegmentPercent > 0 && this.opts.clickToSegmentPercent <= 1) {
7605
+ this.opts.clickToSegmentIntensityMax = voxelIntensity * (1 + this.opts.clickToSegmentPercent)
7606
+ this.opts.clickToSegmentIntensityMin = voxelIntensity * (1 - this.opts.clickToSegmentPercent)
7607
+ }
7608
+ // if clickToSegmentAutoIntensity, then calculate if we need to flood fill
7609
+ // in a bright or dark region based on the intensity of the clicked voxel,
7610
+ // and where it falls in the range of cal_min and cal_max.
7611
+ // !important! If this option is true, then it will ignore the boolean value of
7612
+ // clickToSegmentBright supplied by the user
7613
+ if (this.opts.clickToSegmentAutoIntensity) {
7614
+ // if voxel intensity is greater than the midpoint of cal_min and cal_max,
7615
+ // then flood fill in a bright region
7616
+ if (voxelIntensity > (this.back.cal_min + this.back.cal_max) * 0.5) {
7617
+ this.opts.clickToSegmentBright = true
7618
+ } else {
7619
+ // else flood fill in a dark region
7620
+ this.opts.clickToSegmentBright = false
7621
+ }
7622
+ }
7623
+ // set brightOrDark now, if clickToSegmentAutoBrightOrDark is false,
7624
+ // then brightOrDark will be set to the value of clickToSegmentBright supplied by the user
7190
7625
  const brightOrDark = this.opts.clickToSegmentBright ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY
7191
- this.drawPenFillPts = []
7192
7626
  this.drawPenAxCorSag = axCorSag
7193
- for (let i = 1; i <= steps; i++) {
7194
- const angle = (i / steps) * 2 * Math.PI
7195
- let xVox = pt[0] + radius * Math.cos(angle)
7196
- let yVox = pt[1] + radius * Math.sin(angle)
7197
- const zVox = pt[2]
7198
- xVox = Math.round(xVox)
7199
- yVox = Math.round(yVox)
7200
- this.drawPt(xVox, yVox, zVox, this.opts.penValue)
7201
- this.drawPenFillPts.push([xVox, yVox, pt[2]])
7202
- // fill in the circle if we are at the last step.
7203
- // This also triggers the growth of the circle based on cluster intensity method of flood fill.
7204
- // If the circle is drawn in a bright region, it will grow in the bright region using all connected bright voxels and vice versa.
7205
- if (i === steps) {
7206
- this.drawFloodFill([xVox, yVox, pt[2]], 0, brightOrDark, NaN, NaN, this.opts.floodFillNeighbors)
7207
- }
7627
+ if (this.drawPenAxCorSag === SLICE_TYPE.AXIAL) {
7628
+ // get voxel penSize for the current slice, but what if pixDims are not isotropic?
7629
+ // we will use the smallest pixDim for the current slice
7630
+ const pixDims = [this.back.pixDimsRAS[0], this.back.pixDimsRAS[1]]
7631
+ const minPixDim = Math.min(...pixDims)
7632
+ this.opts.penSize = Math.ceil(this.opts.clickToSegmentRadius / minPixDim)
7633
+ } else if (this.drawPenAxCorSag === SLICE_TYPE.CORONAL) {
7634
+ const pixDims = [this.back.pixDimsRAS[0], this.back.pixDimsRAS[2]]
7635
+ const minPixDim = Math.min(...pixDims)
7636
+ this.opts.penSize = Math.ceil(this.opts.clickToSegmentRadius / minPixDim)
7637
+ } else if (this.drawPenAxCorSag === SLICE_TYPE.SAGITTAL) {
7638
+ const pixDims = [this.back.pixDimsRAS[1], this.back.pixDimsRAS[2]]
7639
+ const minPixDim = Math.min(...pixDims)
7640
+ this.opts.penSize = Math.ceil(this.opts.clickToSegmentRadius / minPixDim)
7208
7641
  }
7642
+ // draw the point at the cursor using the penSize to set the
7643
+ // seed for the flood fill
7644
+ // !important! clickToSegment performs two drawing operations,
7645
+ // therefore, if you want to undo the clickToSegment, you need to call undo twice
7646
+ this.drawPt(pt[0], pt[1], pt[2], this.opts.penValue)
7647
+ this.drawFloodFill(
7648
+ [pt[0], pt[1], pt[2]],
7649
+ 0,
7650
+ brightOrDark,
7651
+ this.opts.clickToSegmentIntensityMin,
7652
+ this.opts.clickToSegmentIntensityMax,
7653
+ this.opts.floodFillNeighbors,
7654
+ this.opts.clickToSegmentMaxDistanceMM
7655
+ )
7209
7656
  this.drawScene()
7210
7657
  this.createOnLocationChange(axCorSag)
7211
7658
  // get the volume of the segmented region
7212
- const info = this.getDescriptives(0, [], true)
7659
+ const info = this.getDescriptives({
7660
+ layer: 0,
7661
+ masks: [],
7662
+ drawingIsMask: true
7663
+ })
7213
7664
  this.onClickToSegment({ mL: info.volumeML, mm3: info.volumeMM3 })
7214
7665
  return
7215
7666
  }
@@ -7461,20 +7912,43 @@ export class Niivue {
7461
7912
  if (!this.rectShader) {
7462
7913
  throw new Error('rectShader undefined')
7463
7914
  }
7464
- this.rectShader.use(this.gl)
7465
- this.gl.enable(this.gl.BLEND)
7466
- this.gl.uniform4fv(this.rectShader.uniforms.lineColor, lineColor)
7467
- this.gl.uniform2fv(this.rectShader.uniforms.canvasWidthHeight, [this.gl.canvas.width, this.gl.canvas.height])
7468
- this.gl.uniform4f(
7469
- this.rectShader.uniforms.leftTopWidthHeight,
7470
- leftTopWidthHeight[0],
7471
- leftTopWidthHeight[1],
7472
- leftTopWidthHeight[2],
7473
- leftTopWidthHeight[3]
7474
- )
7475
- this.gl.bindVertexArray(this.genericVAO)
7476
- this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4)
7477
- this.gl.bindVertexArray(this.unusedVAO) // switch off to avoid tampering with settings
7915
+ if (!this.opts.selectionBoxIsOutline) {
7916
+ this.rectShader.use(this.gl)
7917
+ this.gl.enable(this.gl.BLEND)
7918
+ this.gl.uniform4fv(this.rectShader.uniforms.lineColor, lineColor)
7919
+ this.gl.uniform2fv(this.rectShader.uniforms.canvasWidthHeight, [this.gl.canvas.width, this.gl.canvas.height])
7920
+ this.gl.uniform4f(
7921
+ this.rectShader.uniforms.leftTopWidthHeight,
7922
+ leftTopWidthHeight[0],
7923
+ leftTopWidthHeight[1],
7924
+ leftTopWidthHeight[2],
7925
+ leftTopWidthHeight[3]
7926
+ )
7927
+ this.gl.bindVertexArray(this.genericVAO)
7928
+ this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4)
7929
+ this.gl.bindVertexArray(this.unusedVAO) // switch off to avoid tampering with settings
7930
+ } else {
7931
+ // this.opts.selectionBoxIsOutline == true
7932
+ this.rectOutlineShader.use(this.gl)
7933
+ this.gl.enable(this.gl.BLEND)
7934
+ // set thickness of line
7935
+ this.gl.uniform1f(this.rectOutlineShader.uniforms.thickness, this.opts.selectionBoxLineThickness)
7936
+ this.gl.uniform4fv(this.rectOutlineShader.uniforms.lineColor, lineColor)
7937
+ this.gl.uniform2fv(this.rectOutlineShader.uniforms.canvasWidthHeight, [
7938
+ this.gl.canvas.width,
7939
+ this.gl.canvas.height
7940
+ ])
7941
+ this.gl.uniform4f(
7942
+ this.rectOutlineShader.uniforms.leftTopWidthHeight,
7943
+ leftTopWidthHeight[0],
7944
+ leftTopWidthHeight[1],
7945
+ leftTopWidthHeight[2],
7946
+ leftTopWidthHeight[3]
7947
+ )
7948
+ this.gl.bindVertexArray(this.genericVAO)
7949
+ this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4)
7950
+ this.gl.bindVertexArray(this.unusedVAO) // switch off to avoid tampering with settings
7951
+ }
7478
7952
  }
7479
7953
 
7480
7954
  drawCircle(leftTopWidthHeight: number[], circleColor = this.opts.fontColor, fillPercent = 1.0): void {
@@ -7526,9 +8000,26 @@ export class Niivue {
7526
8000
  return labels
7527
8001
  }
7528
8002
 
8003
+ getConnectomeLabels(): NVLabel3D[] {
8004
+ const connectomes = this.meshes.filter((m) => m.type === MeshType.CONNECTOME)
8005
+ const meshNodes = connectomes.flatMap((m) => m.nodes as NVConnectomeNode[])
8006
+ const meshLabels = meshNodes.map((n) => n.label)
8007
+ // filter our undefined labels
8008
+ const definedMeshLabels = meshLabels.filter((l): l is NVLabel3D => l !== undefined)
8009
+ // get all of our non-anchored labels
8010
+ const nonAnchoredLabels = this.document.labels.filter((l) => l.anchor == null || l.anchor === LabelAnchorPoint.NONE)
8011
+ // get the unique set of unanchored labels
8012
+ const nonAnchoredLabelSet = new Set(definedMeshLabels)
8013
+ for (const label of nonAnchoredLabels) {
8014
+ nonAnchoredLabelSet.add(label)
8015
+ }
8016
+
8017
+ return Array.from(nonAnchoredLabelSet)
8018
+ }
8019
+
7529
8020
  getBulletMarginWidth(): number {
7530
8021
  let bulletMargin = 0
7531
- const labels = this.getAllLabels()
8022
+ const labels = this.getConnectomeLabels()
7532
8023
  if (labels.length === 0) {
7533
8024
  return 0
7534
8025
  }
@@ -7553,7 +8044,7 @@ export class Niivue {
7553
8044
  }
7554
8045
 
7555
8046
  getLegendPanelWidth(): number {
7556
- const labels = this.getAllLabels()
8047
+ const labels = this.getConnectomeLabels()
7557
8048
  if (!this.opts.showLegend || labels.length === 0) {
7558
8049
  return 0
7559
8050
  }
@@ -7584,7 +8075,7 @@ export class Niivue {
7584
8075
  }
7585
8076
 
7586
8077
  getLegendPanelHeight(): number {
7587
- const labels = this.getAllLabels()
8078
+ const labels = this.getConnectomeLabels()
7588
8079
  let height = 0
7589
8080
  const scale = 1.0 // we may want to make this adjustable in the future
7590
8081
  const verticalMargin = this.opts.textHeight * this.gl.canvas.height * scale
@@ -8099,7 +8590,11 @@ export class Niivue {
8099
8590
 
8100
8591
  // not included in public docs
8101
8592
  // show text labels for L/R, A/P, I/S dimensions
8102
- drawSliceOrientationText(leftTopWidthHeight: number[], axCorSag: SLICE_TYPE): void {
8593
+ drawSliceOrientationText(
8594
+ leftTopWidthHeight: number[],
8595
+ axCorSag: SLICE_TYPE,
8596
+ padLeftTop: number[] = [NaN, NaN]
8597
+ ): void {
8103
8598
  this.gl.viewport(0, 0, this.gl.canvas.width, this.gl.canvas.height)
8104
8599
  let topText = 'S'
8105
8600
  if (axCorSag === SLICE_TYPE.AXIAL) {
@@ -8113,9 +8608,32 @@ export class Niivue {
8113
8608
  this.drawTextRightBelow([leftTopWidthHeight[0], leftTopWidthHeight[1]], leftText + topText)
8114
8609
  return
8115
8610
  }
8116
- this.drawTextBelow([leftTopWidthHeight[0] + leftTopWidthHeight[2] * 0.5, leftTopWidthHeight[1]], topText)
8117
-
8118
- this.drawTextRight([leftTopWidthHeight[0], leftTopWidthHeight[1] + leftTopWidthHeight[3] * 0.5], leftText)
8611
+ let drawBelow = true
8612
+ let drawRight = true
8613
+ if (!isNaN(padLeftTop[0])) {
8614
+ const ht = this.opts.textHeight * this.gl.canvas.height + 2
8615
+ if (padLeftTop[1] > ht) {
8616
+ this.drawTextBelow(
8617
+ [leftTopWidthHeight[0] + leftTopWidthHeight[2] * 0.5, leftTopWidthHeight[1] + padLeftTop[1] - ht],
8618
+ topText
8619
+ )
8620
+ drawBelow = false
8621
+ }
8622
+ const wid = this.textWidth(ht, leftText) + 2
8623
+ if (padLeftTop[0] > wid) {
8624
+ this.drawTextRight(
8625
+ [leftTopWidthHeight[0] + padLeftTop[0] - wid, leftTopWidthHeight[1] + leftTopWidthHeight[3] * 0.5],
8626
+ leftText
8627
+ )
8628
+ drawRight = false
8629
+ }
8630
+ }
8631
+ if (drawBelow) {
8632
+ this.drawTextBelow([leftTopWidthHeight[0] + leftTopWidthHeight[2] * 0.5, leftTopWidthHeight[1]], topText)
8633
+ }
8634
+ if (drawRight) {
8635
+ this.drawTextRight([leftTopWidthHeight[0], leftTopWidthHeight[1] + leftTopWidthHeight[3] * 0.5], leftText)
8636
+ }
8119
8637
  }
8120
8638
 
8121
8639
  // not included in public docs
@@ -8158,7 +8676,7 @@ export class Niivue {
8158
8676
 
8159
8677
  // not included in public docs
8160
8678
  // draw 2D tile
8161
- draw2D(leftTopWidthHeight: number[], axCorSag: SLICE_TYPE, customMM = NaN): void {
8679
+ draw2DMain(leftTopWidthHeight: number[], axCorSag: SLICE_TYPE, customMM = NaN): void {
8162
8680
  let frac2mmTexture = this.volumes[0].frac2mm!.slice()
8163
8681
  let screen = this.screenFieldOfViewExtendedMM(axCorSag)
8164
8682
  let mesh2ortho = mat4.create()
@@ -8187,8 +8705,10 @@ export class Niivue {
8187
8705
  elevation = isRadiolgical ? -90 : 90
8188
8706
  }
8189
8707
  const gl = this.gl
8708
+ let isStretchToScreen = false
8190
8709
  if (leftTopWidthHeight[2] === 0 || leftTopWidthHeight[3] === 0) {
8191
8710
  // only one tile: stretch tile to fill whole screen.
8711
+ isStretchToScreen = true
8192
8712
  const pixPerMMw = gl.canvas.width / screen.fovMM[0]
8193
8713
  const pixPerMMh = gl.canvas.height / screen.fovMM[1]
8194
8714
  const pixPerMMmin = Math.min(pixPerMMw, pixPerMMh)
@@ -8347,10 +8867,42 @@ export class Niivue {
8347
8867
  // no crossbars for mosaic view
8348
8868
  this.drawCrosshairs3D(false, 0.15, obj.modelViewProjectionMatrix, true, this.opts.isSliceMM)
8349
8869
  }
8350
- this.drawSliceOrientationText(leftTopWidthHeight, axCorSag)
8870
+ if (isStretchToScreen) {
8871
+ // issue1065
8872
+ this.drawSliceOrientationText(leftTopWidthHeight, axCorSag)
8873
+ }
8351
8874
  this.readyForSync = true
8352
8875
  }
8353
8876
 
8877
+ draw2D(
8878
+ leftTopWidthHeight: number[],
8879
+ axCorSag: SLICE_TYPE,
8880
+ customMM = NaN,
8881
+ imageWidthHeight: number[] = [NaN, NaN]
8882
+ ): void {
8883
+ const padLeftTop = [NaN, NaN]
8884
+ if (isNaN(imageWidthHeight[0])) {
8885
+ this.draw2DMain(leftTopWidthHeight, axCorSag, customMM)
8886
+ } else {
8887
+ // inset as padded in tile
8888
+ const ltwh = leftTopWidthHeight.slice()
8889
+ padLeftTop[0] = Math.floor(0.5 * (ltwh[2] - imageWidthHeight[0]))
8890
+ padLeftTop[1] = Math.floor(0.5 * (ltwh[3] - imageWidthHeight[1]))
8891
+ ltwh[0] += padLeftTop[0]
8892
+ ltwh[1] += padLeftTop[1]
8893
+ ltwh[2] = imageWidthHeight[0]
8894
+ ltwh[3] = imageWidthHeight[1]
8895
+ this.draw2DMain(ltwh, axCorSag, customMM)
8896
+ }
8897
+ if (customMM === Infinity || customMM === -Infinity || axCorSag === SLICE_TYPE.RENDER) {
8898
+ return
8899
+ }
8900
+ if (leftTopWidthHeight[2] !== 0 && leftTopWidthHeight[3] !== 0) {
8901
+ // issue1065
8902
+ this.drawSliceOrientationText(leftTopWidthHeight, axCorSag, padLeftTop)
8903
+ }
8904
+ }
8905
+
8354
8906
  // not included in public docs
8355
8907
  // determine 3D model view projection matrix
8356
8908
  calculateMvpMatrix(_unused: unknown, leftTopWidthHeight = [0, 0, 0, 0], azimuth: number, elevation: number): mat4[] {
@@ -9049,7 +9601,13 @@ export class Niivue {
9049
9601
  * @param style - label style
9050
9602
  * @param point - 3D point on the model
9051
9603
  */
9052
- addLabel(text: string, style: NVLabel3DStyle, points?: number[] | number[][]): NVLabel3D {
9604
+ addLabel(
9605
+ text: string,
9606
+ style: NVLabel3DStyle,
9607
+ points?: number[] | number[][],
9608
+ anchor?: LabelAnchorPoint,
9609
+ onClick?: (label: NVLabel3D) => void
9610
+ ): NVLabel3D {
9053
9611
  const defaultStyle = {
9054
9612
  textColor: this.opts.legendTextColor,
9055
9613
  textScale: 1.0,
@@ -9061,7 +9619,7 @@ export class Niivue {
9061
9619
  bulletColor: this.opts.legendTextColor
9062
9620
  }
9063
9621
  const labelStyle = style ? { ...defaultStyle, ...style } : { ...defaultStyle }
9064
- const label = new NVLabel3D(text, labelStyle, points)
9622
+ const label = new NVLabel3D(text, { ...labelStyle }, points, anchor, onClick)
9065
9623
  this.document.labels.push(label)
9066
9624
  return label
9067
9625
  }
@@ -9084,6 +9642,75 @@ export class Niivue {
9084
9642
  }
9085
9643
 
9086
9644
  getLabelAtPoint(screenPoint: [number, number]): NVLabel3D | null {
9645
+ const scale = 1.0
9646
+ const size = this.opts.textHeight * Math.min(this.gl.canvas.height, this.gl.canvas.width) * scale
9647
+ const verticalMargin = this.opts.textHeight * this.gl.canvas.height * scale
9648
+
9649
+ // get all non-connectome labels
9650
+ for (const label of this.document.labels) {
9651
+ if (label.anchor == null || label.anchor === LabelAnchorPoint.NONE) {
9652
+ continue
9653
+ }
9654
+
9655
+ const labelSize = this.opts.textHeight * this.gl.canvas.height * label.style.textScale
9656
+ const textHeight = this.textHeight(labelSize, label.text)
9657
+ const textWidth = this.textWidth(labelSize, label.text)
9658
+
9659
+ if (label.anchor & LabelAnchorFlag.LEFT) {
9660
+ if (screenPoint[0] > textWidth) {
9661
+ continue
9662
+ }
9663
+ }
9664
+
9665
+ if (label.anchor & LabelAnchorFlag.CENTER) {
9666
+ if (screenPoint[0] < (this.gl.canvas.width - textWidth) / 2) {
9667
+ continue
9668
+ }
9669
+
9670
+ if (screenPoint[0] > (this.gl.canvas.width + textWidth) / 2) {
9671
+ continue
9672
+ }
9673
+ }
9674
+
9675
+ if (label.anchor & LabelAnchorFlag.RIGHT) {
9676
+ if (screenPoint[0] < this.gl.canvas.width - textWidth) {
9677
+ continue
9678
+ }
9679
+ }
9680
+
9681
+ if (label.anchor & LabelAnchorFlag.TOP) {
9682
+ if (screenPoint[1] < verticalMargin / 2) {
9683
+ continue
9684
+ }
9685
+
9686
+ if (screenPoint[1] > textHeight + verticalMargin / 2) {
9687
+ continue
9688
+ }
9689
+ }
9690
+
9691
+ if (label.anchor & LabelAnchorFlag.MIDDLE) {
9692
+ if (screenPoint[1] < (this.gl.canvas.height - textHeight - verticalMargin) / 2) {
9693
+ continue
9694
+ }
9695
+
9696
+ if (screenPoint[1] > (this.gl.canvas.height + textHeight - verticalMargin / 2) / 2) {
9697
+ continue
9698
+ }
9699
+ }
9700
+
9701
+ if (label.anchor & LabelAnchorFlag.BOTTOM) {
9702
+ if (screenPoint[1] < this.gl.canvas.height - textHeight - verticalMargin) {
9703
+ continue
9704
+ }
9705
+
9706
+ if (screenPoint[1] > this.gl.canvas.height - verticalMargin / 2) {
9707
+ continue
9708
+ }
9709
+ }
9710
+
9711
+ // label passed all tests
9712
+ return label
9713
+ }
9087
9714
  log.debug('screenPoint', screenPoint)
9088
9715
  const panelHeight = this.getLegendPanelHeight()
9089
9716
  const panelWidth = this.getLegendPanelWidth()
@@ -9099,10 +9726,7 @@ export class Niivue {
9099
9726
  return null
9100
9727
  }
9101
9728
 
9102
- const scale = 1.0
9103
- const size = this.opts.textHeight * Math.min(this.gl.canvas.height, this.gl.canvas.width) * scale
9104
-
9105
- const labels = this.getAllLabels()
9729
+ const labels = this.getConnectomeLabels()
9106
9730
  for (const label of labels) {
9107
9731
  const labelSize = this.opts.textHeight * this.gl.canvas.height * label.style.textScale
9108
9732
  const textHeight = this.textHeight(labelSize, label.text)
@@ -9140,11 +9764,11 @@ export class Niivue {
9140
9764
  draw3DLabel(
9141
9765
  label: NVLabel3D,
9142
9766
  pos: vec2,
9143
- mvpMatrix: mat4,
9144
- leftTopWidthHeight: number[],
9145
- bulletMargin = 0,
9146
- legendWidth: number,
9147
- secondPass = false
9767
+ mvpMatrix?: mat4,
9768
+ leftTopWidthHeight?: number[],
9769
+ bulletMargin?: number,
9770
+ legendWidth?: number,
9771
+ secondPass?: boolean
9148
9772
  ): void {
9149
9773
  const text = label.text
9150
9774
  const left = pos[0]
@@ -9170,17 +9794,19 @@ export class Niivue {
9170
9794
 
9171
9795
  let textLeft = left
9172
9796
 
9173
- if (label.style.textAlignment !== LabelTextAlignment.LEFT) {
9174
- const textWidth = this.textWidth(label.style.textScale, label.text) * size
9175
- if (label.style.textAlignment === LabelTextAlignment.RIGHT) {
9176
- textLeft = left + legendWidth - size * 1.5 - textWidth
9797
+ if (legendWidth) {
9798
+ if (label.style.textAlignment !== LabelTextAlignment.LEFT) {
9799
+ const textWidth = this.textWidth(label.style.textScale, label.text) * size
9800
+ if (label.style.textAlignment === LabelTextAlignment.RIGHT) {
9801
+ textLeft = left + legendWidth - size * 1.5 - textWidth
9802
+ } else {
9803
+ const remaining = legendWidth - (bulletMargin || size)
9804
+ textLeft += (remaining - textWidth) / 2
9805
+ }
9177
9806
  } else {
9178
- const remaining = legendWidth - (bulletMargin || size)
9179
- textLeft += (remaining - textWidth) / 2
9807
+ // textLeft += size / 2;
9808
+ textLeft += bulletMargin
9180
9809
  }
9181
- } else {
9182
- // textLeft += size / 2;
9183
- textLeft += bulletMargin
9184
9810
  }
9185
9811
 
9186
9812
  this.drawText([textLeft, top], text, label.style.textScale, label.style.textColor)
@@ -9188,7 +9814,7 @@ export class Niivue {
9188
9814
 
9189
9815
  // not included in public docs
9190
9816
  draw3DLabels(mvpMatrix: mat4, leftTopWidthHeight: number[], secondPass = false): void {
9191
- const labels = this.getAllLabels()
9817
+ const labels = this.getConnectomeLabels()
9192
9818
  if (!this.opts.showLegend || labels.length === 0) {
9193
9819
  return
9194
9820
  }
@@ -9238,6 +9864,59 @@ export class Niivue {
9238
9864
  }
9239
9865
  }
9240
9866
 
9867
+ drawAnchoredLabels(): void {
9868
+ const size = this.opts.textHeight * Math.min(this.gl.canvas.height, this.gl.canvas.width) * 1.0
9869
+ const anchoredLabels = this.document.labels.filter((l) => l.anchor != null && l.anchor !== LabelAnchorPoint.NONE)
9870
+ for (const label of anchoredLabels) {
9871
+ const text = label.text
9872
+ const textHeight = this.textHeight(label.style.textScale, text) * size
9873
+ const textWidth = this.textWidth(label.style.textScale, text) * size
9874
+ let left: number
9875
+ let top: number
9876
+
9877
+ const scale = 1.0 // we may want to make this adjustable in the future
9878
+ const verticalMargin = this.opts.textHeight * this.gl.canvas.height * scale
9879
+ const rectHeightDiff = verticalMargin
9880
+ let rectWidthDiff = verticalMargin / 4
9881
+ let rectHorizontalOffset = 0
9882
+ let rectVerticalOffset = 0
9883
+
9884
+ if (label.anchor & LabelAnchorFlag.LEFT) {
9885
+ left = 0
9886
+ }
9887
+
9888
+ if (label.anchor & LabelAnchorFlag.RIGHT) {
9889
+ left = this.canvas.width - textWidth
9890
+ rectHorizontalOffset -= verticalMargin / 4
9891
+ }
9892
+
9893
+ if (label.anchor & LabelAnchorFlag.CENTER) {
9894
+ left = (this.canvas.width - textWidth) / 2
9895
+ rectHorizontalOffset -= verticalMargin / 4
9896
+ rectWidthDiff += verticalMargin / 4
9897
+ }
9898
+
9899
+ if (label.anchor & LabelAnchorFlag.TOP) {
9900
+ top = 0
9901
+ }
9902
+
9903
+ if (label.anchor & LabelAnchorFlag.MIDDLE) {
9904
+ top = (this.canvas.height - textHeight - verticalMargin) / 2
9905
+ rectVerticalOffset -= verticalMargin / 4
9906
+ }
9907
+
9908
+ if (label.anchor & LabelAnchorFlag.BOTTOM) {
9909
+ top = this.canvas.height - textHeight - verticalMargin
9910
+ rectVerticalOffset -= verticalMargin / 4
9911
+ }
9912
+ this.drawRect(
9913
+ [left + rectHorizontalOffset, top + rectVerticalOffset, textWidth + rectWidthDiff, textHeight + rectHeightDiff],
9914
+ label.style.backgroundColor
9915
+ )
9916
+ this.draw3DLabel(label, [left, top])
9917
+ }
9918
+ }
9919
+
9241
9920
  // not included in public docs
9242
9921
  draw3D(
9243
9922
  leftTopWidthHeight = [0, 0, 0, 0],
@@ -9330,6 +10009,7 @@ export class Niivue {
9330
10009
  this.readyForSync = true
9331
10010
  this.sync()
9332
10011
  this.draw3DLabels(mvpMatrix, relativeLTWH, true)
10012
+
9333
10013
  return posString
9334
10014
  }
9335
10015
 
@@ -10265,7 +10945,14 @@ export class Niivue {
10265
10945
  }
10266
10946
  }
10267
10947
  const isDrawPenDown = isFinite(this.drawPenLocation[0]) && this.opts.drawingEnabled
10948
+
10268
10949
  const { volScale } = this.sliceScale()
10950
+ const actualScale = volScale.slice()
10951
+ if (this.opts.multiplanarEqualSize) {
10952
+ volScale[0] = 1
10953
+ volScale[1] = 1
10954
+ volScale[2] = 1
10955
+ }
10269
10956
  if (typeof this.opts.multiplanarPadPixels !== 'number') {
10270
10957
  log.debug('multiplanarPadPixels must be numeric')
10271
10958
  }
@@ -10310,44 +10997,49 @@ export class Niivue {
10310
10997
  isDrawGrid = true
10311
10998
  }
10312
10999
  }
11000
+ let ltwh = ltwh2x2
10313
11001
  if (isDrawColumn) {
10314
- let ltwh = ltwh1x3
11002
+ ltwh = ltwh1x3
10315
11003
  if (isShowRender || (this.opts.multiplanarShowRender === SHOW_RENDER.AUTO && ltwh1x4[4] >= ltwh1x3[4])) {
10316
11004
  ltwh = ltwh1x4
10317
11005
  } else {
10318
11006
  isDraw3D = false
10319
11007
  }
10320
- const sX = volScale[0] * ltwh[4]
10321
- const sY = volScale[1] * ltwh[4]
10322
- const sZ = volScale[2] * ltwh[4]
10323
- const sMx = mx * ltwh[4]
11008
+ } else if (isDrawRow) {
11009
+ ltwh = ltwh3x1
11010
+ if (isShowRender || (this.opts.multiplanarShowRender === SHOW_RENDER.AUTO && ltwh4x1[4] >= ltwh3x1[4])) {
11011
+ ltwh = ltwh4x1
11012
+ } else {
11013
+ isDraw3D = false
11014
+ }
11015
+ }
11016
+ const sX = volScale[0] * ltwh[4]
11017
+ const sY = volScale[1] * ltwh[4]
11018
+ const sZ = volScale[2] * ltwh[4]
11019
+ const actualX = actualScale[0] * ltwh[4]
11020
+ const actualY = actualScale[1] * ltwh[4]
11021
+ const actualZ = actualScale[2] * ltwh[4]
11022
+ if (isDrawColumn) {
10324
11023
  // draw axial
10325
- this.draw2D([ltwh[0], ltwh[1], sX, sY], 0)
11024
+ this.draw2D([ltwh[0], ltwh[1], sX, sY], 0, NaN, [actualX, actualY])
10326
11025
  // draw coronal
10327
- this.draw2D([ltwh[0], ltwh[1] + sY + pad, sX, sZ], 1)
11026
+ this.draw2D([ltwh[0], ltwh[1] + sY + pad, sX, sZ], 1, NaN, [actualX, actualZ])
10328
11027
  // draw sagittal
10329
- this.draw2D([ltwh[0], ltwh[1] + sY + pad + sZ + pad, sY, sZ], 2)
11028
+ this.draw2D([ltwh[0], ltwh[1] + sY + pad + sZ + pad, sY, sZ], 2, NaN, [actualY, actualZ])
10330
11029
  if (isDraw3D) {
11030
+ const sMx = mx * ltwh[4]
10331
11031
  this.draw3D([ltwh[0], ltwh[1] + sY + sZ + sZ + pad * 3, sMx, sMx])
10332
11032
  }
10333
11033
  } else if (isDrawRow) {
10334
- let ltwh = ltwh3x1
10335
- if (isShowRender || (this.opts.multiplanarShowRender === SHOW_RENDER.AUTO && ltwh4x1[4] >= ltwh3x1[4])) {
10336
- ltwh = ltwh4x1
10337
- } else {
10338
- isDraw3D = false
10339
- }
10340
- const sX = volScale[0] * ltwh[4]
10341
- const sY = volScale[1] * ltwh[4]
10342
- const sZ = volScale[2] * ltwh[4]
10343
11034
  // draw axial
10344
- this.draw2D([ltwh[0], ltwh[1], sX, sY], 0)
11035
+ this.draw2D([ltwh[0], ltwh[1], sX, sY], 0, NaN, [actualX, actualY])
10345
11036
  // draw coronal
10346
- this.draw2D([ltwh[0] + sX + pad, ltwh[1], sX, sZ], 1)
11037
+ this.draw2D([ltwh[0] + sX + pad, ltwh[1], sX, sZ], 1, NaN, [actualX, actualZ])
10347
11038
  // draw sagittal
10348
- this.draw2D([ltwh[0] + sX + sX + pad * 2, ltwh[1], sY, sZ], 2)
11039
+ this.draw2D([ltwh[0] + sX + sX + pad * 2, ltwh[1], sY, sZ], 2, NaN, [actualY, actualZ])
10349
11040
  if (isDraw3D) {
10350
- this.draw3D([ltwh[0] + sX + sX + sY + pad * 3, ltwh[1], ltwh[3], ltwh[3]])
11041
+ const sMx = mx * ltwh[4]
11042
+ this.draw3D([ltwh[0] + sX + sX + sY + pad * 3, ltwh[1], sMx, sMx])
10351
11043
  }
10352
11044
  } else if (isDrawGrid) {
10353
11045
  // did the user turn off 3D render view in multiplanar?
@@ -10358,16 +11050,12 @@ export class Niivue {
10358
11050
  if (this.opts.multiplanarShowRender === SHOW_RENDER.AUTO) {
10359
11051
  isDraw3D = true
10360
11052
  }
10361
- const ltwh = ltwh2x2
10362
- const sX = volScale[0] * ltwh[4]
10363
- const sY = volScale[1] * ltwh[4]
10364
- const sZ = volScale[2] * ltwh[4]
10365
11053
  // draw axial
10366
- this.draw2D([ltwh[0], ltwh[1] + sZ + pad, sX, sY], 0)
11054
+ this.draw2D([ltwh[0], ltwh[1] + sZ + pad, sX, sY], 0, NaN, [actualX, actualY])
10367
11055
  // draw coronal
10368
- this.draw2D([ltwh[0], ltwh[1], sX, sZ], 1)
11056
+ this.draw2D([ltwh[0], ltwh[1], sX, sZ], 1, NaN, [actualX, actualZ])
10369
11057
  // draw sagittal
10370
- this.draw2D([ltwh[0] + sX + pad, ltwh[1], sY, sZ], 2)
11058
+ this.draw2D([ltwh[0] + sX + pad, ltwh[1], sY, sZ], 2, NaN, [actualY, actualZ])
10371
11059
  if (isDraw3D) {
10372
11060
  this.draw3D([ltwh[0] + sX + pad, ltwh[1] + sZ + pad, sY, sY])
10373
11061
  }
@@ -10432,12 +11120,44 @@ export class Niivue {
10432
11120
  width,
10433
11121
  height
10434
11122
  ])
11123
+ return
11124
+ }
11125
+
11126
+ // draw circle at mouse position if clickToSegment is enabled
11127
+ if (this.opts.clickToSegment) {
11128
+ const x = this.mousePos[0]
11129
+ const y = this.mousePos[1]
11130
+ // check if hovering over the 3D render tile
11131
+ if (this.inRenderTile(x, y) >= 0) {
11132
+ // exit early since we do not want to draw the cursor here!
11133
+ return
11134
+ }
11135
+ // determine the tile the mouse is hovering in
11136
+ const tileIdx = this.tileIndex(x, y)
11137
+ // if a valid tile index, draw the circle
11138
+ if (tileIdx > -1) {
11139
+ // get fov in mm for this plane presented in the tile
11140
+ const fovMM = this.screenSlices[tileIdx].fovMM
11141
+ // get the left, top, width, height of the tile in pixels
11142
+ const ltwh = this.screenSlices[tileIdx].leftTopWidthHeight
11143
+ // calculate the pixel to mm scale so we can draw the circle
11144
+ // in pixels (so it is highres) but with the radius specified in mm
11145
+ const pixPerMM = ltwh[2] / fovMM[0]
11146
+ // get the crosshair color, but replace the alpha because we want it to be transparent
11147
+ // no matter what. We want to see the image data underneath the circle.
11148
+ const color = this.opts.crosshairColor
11149
+ const segmentCursorColor = [color[0], color[1], color[2], 0.4]
11150
+ const radius = this.opts.clickToSegmentRadius * pixPerMM
11151
+ this.drawCircle([x - radius, y - radius, radius * 2, radius * 2], segmentCursorColor, 1)
11152
+ }
10435
11153
  }
11154
+
10436
11155
  const pos = this.frac2mm([this.scene.crosshairPos[0], this.scene.crosshairPos[1], this.scene.crosshairPos[2]])
10437
11156
 
10438
11157
  posString = pos[0].toFixed(2) + '×' + pos[1].toFixed(2) + '×' + pos[2].toFixed(2)
10439
11158
  this.readyForSync = true // by the time we get here, all volumes should be loaded and ready to be drawn. We let other niivue instances know that we can now reliably sync draw calls (images are loaded)
10440
11159
  this.sync()
11160
+ this.drawAnchoredLabels()
10441
11161
  return posString
10442
11162
  }
10443
11163