@niivue/niivue 0.44.1 → 0.44.2-dev.2

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,7 +131,7 @@ 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'
126
136
  export { NVMeshUtilities } from '../nvmesh-utilities.js'
127
137
 
@@ -186,6 +196,7 @@ type Descriptive = {
186
196
  cal_max: number
187
197
  robust_min: number
188
198
  robust_max: number
199
+ area: number | null
189
200
  }
190
201
 
191
202
  type SliceScale = {
@@ -334,6 +345,9 @@ export class Niivue {
334
345
  drawUndoBitmaps: Uint8Array[] = [] // array of drawBitmaps for undo
335
346
  drawLut = cmapper.makeDrawLut('$itksnap') // the color lookup table for drawing
336
347
  drawOpacity = 0.8 // opacity of drawing (default)
348
+ clickToSegmentIsGrowing = false // flag to indicate if the clickToSegment flood fill growing is in progress with left mouse down + drag
349
+ clickToSegmentGrowingBitmap: Uint8Array | null = null // the bitmap of the growing flood fill
350
+ clickToSegmentXY = [0, 0] // the x,y location of the clickToSegment flood fill
337
351
  renderDrawAmbientOcclusion = 0.4
338
352
  colorbarHeight = 0 // height in pixels, set when colorbar is drawn
339
353
  drawPenLocation = [NaN, NaN, NaN]
@@ -347,6 +361,7 @@ export class Niivue {
347
361
  orientCubeShader?: Shader
348
362
  orientCubeShaderVAO: WebGLVertexArrayObject | null = null
349
363
  rectShader?: Shader
364
+ rectOutlineShader?: Shader
350
365
  renderShader?: Shader
351
366
  lineShader?: Shader
352
367
  line3DShader?: Shader
@@ -392,7 +407,19 @@ export class Niivue {
392
407
  extentsMax?: vec3
393
408
  // ResizeObserver
394
409
  private resizeObserver: ResizeObserver | null = null
395
- syncOpts: Record<string, unknown> = {}
410
+ // syncOpts: Record<string, unknown> = {}
411
+ syncOpts: SyncOpts = {
412
+ '3d': false, // legacy option
413
+ '2d': false, // legacy option
414
+ zoomPan: false,
415
+ cal_min: false,
416
+ cal_max: false,
417
+ clipPlane: false,
418
+ gamma: false,
419
+ sliceType: false,
420
+ crosshair: false
421
+ }
422
+
396
423
  readyForSync = false
397
424
 
398
425
  // UI Data
@@ -442,7 +469,7 @@ export class Niivue {
442
469
 
443
470
  cuboidVertexBuffer?: WebGLBuffer
444
471
 
445
- otherNV: Niivue | Niivue[] | null = null // another niivue instance that we wish to sync position with
472
+ otherNV: Niivue[] | null = null // another niivue instance that we wish to sync position with
446
473
  volumeObject3D: NiivueObject3D | null = null
447
474
  pivot3D = [0, 0, 0] // center for rendering rotation
448
475
  furthestFromPivot = 10.0 // most distant point from pivot
@@ -479,6 +506,10 @@ export class Niivue {
479
506
  Name: 'Hemispheric',
480
507
  Frag: fragMeshHemiShader
481
508
  },
509
+ {
510
+ Name: 'Crevice',
511
+ Frag: fragMeshShaderCrevice
512
+ },
482
513
  {
483
514
  Name: 'Edge',
484
515
  Frag: fragMeshEdgeShader
@@ -907,7 +938,11 @@ export class Niivue {
907
938
  * @deprecated use broadcastTo instead
908
939
  * @see {@link https://niivue.github.io/niivue/features/sync.mesh.html | live demo usage}
909
940
  */
910
- syncWith(otherNV: Niivue, syncOpts = { '2d': true, '3d': true }): void {
941
+ syncWith(otherNV: Niivue | Niivue[], syncOpts = { '2d': true, '3d': true }): void {
942
+ // if otherNV is not an array, make it an array of one
943
+ if (!(otherNV instanceof Array)) {
944
+ otherNV = [otherNV]
945
+ }
911
946
  this.otherNV = otherNV
912
947
  this.syncOpts = { ...syncOpts }
913
948
  }
@@ -924,10 +959,71 @@ export class Niivue {
924
959
  * @see {@link https://niivue.github.io/niivue/features/sync.mesh.html | live demo usage}
925
960
  */
926
961
  broadcastTo(otherNV: Niivue | Niivue[], syncOpts = { '2d': true, '3d': true }): void {
962
+ // if otherNV is a single instance then make it an array of one
963
+ if (!(otherNV instanceof Array)) {
964
+ otherNV = [otherNV]
965
+ }
927
966
  this.otherNV = otherNV
928
967
  this.syncOpts = syncOpts
929
968
  }
930
969
 
970
+ doSync3d(otherNV: Niivue): void {
971
+ otherNV.scene.renderAzimuth = this.scene.renderAzimuth
972
+ otherNV.scene.renderElevation = this.scene.renderElevation
973
+ otherNV.scene.volScaleMultiplier = this.scene.volScaleMultiplier
974
+ }
975
+
976
+ // both crosshair and zoomPan
977
+ doSync2d(otherNV: Niivue): void {
978
+ const thisMM = this.frac2mm(this.scene.crosshairPos)
979
+ otherNV.scene.crosshairPos = otherNV.mm2frac(thisMM)
980
+ otherNV.scene.pan2Dxyzmm = vec4.clone(this.scene.pan2Dxyzmm)
981
+ }
982
+
983
+ doSyncGamma(otherNV: Niivue): void {
984
+ // gamma not dependent on 2d/3d
985
+ const thisGamma = this.scene.gamma
986
+ const otherGamma = otherNV.scene.gamma
987
+ if (thisGamma !== otherGamma) {
988
+ otherNV.setGamma(thisGamma)
989
+ }
990
+ }
991
+
992
+ doSyncZoomPan(otherNV: Niivue): void {
993
+ otherNV.scene.pan2Dxyzmm = vec4.clone(this.scene.pan2Dxyzmm)
994
+ }
995
+
996
+ doSyncCrosshair(otherNV: Niivue): void {
997
+ const thisMM = this.frac2mm(this.scene.crosshairPos)
998
+ otherNV.scene.crosshairPos = otherNV.mm2frac(thisMM)
999
+ }
1000
+
1001
+ doSyncCalMin(otherNV: Niivue): void {
1002
+ // only call updateGLVolume if the cal_min is different
1003
+ // because updateGLVolume is expensive, but required to update the volume
1004
+ if (this.volumes[0].cal_min !== otherNV.volumes[0].cal_min) {
1005
+ otherNV.volumes[0].cal_min = this.volumes[0].cal_min
1006
+ otherNV.updateGLVolume()
1007
+ }
1008
+ }
1009
+
1010
+ doSyncCalMax(otherNV: Niivue): void {
1011
+ // only call updateGLVolume if the cal_max is different
1012
+ // because updateGLVolume is expensive, but required to update the volume
1013
+ if (this.volumes[0].cal_max !== otherNV.volumes[0].cal_max) {
1014
+ otherNV.volumes[0].cal_max = this.volumes[0].cal_max
1015
+ otherNV.updateGLVolume()
1016
+ }
1017
+ }
1018
+
1019
+ doSyncSliceType(otherNV: Niivue): void {
1020
+ otherNV.setSliceType(this.opts.sliceType)
1021
+ }
1022
+
1023
+ doSyncClipPlane(otherNV: Niivue): void {
1024
+ otherNV.setClipPlane(this.scene.clipPlaneDepthAziElev)
1025
+ }
1026
+
931
1027
  /**
932
1028
  * Sync the scene controls (orientation, crosshair location, etc.) from one Niivue instance to another. useful for using one canvas to drive another.
933
1029
  * @internal
@@ -941,44 +1037,52 @@ export class Niivue {
941
1037
  if (!this.gl || !this.otherNV || typeof this.otherNV === 'undefined') {
942
1038
  return
943
1039
  }
944
- // if (!this.otherNV.readyForSync || !this.readyForSync) {
945
- // return;
946
- // }
947
1040
  // canvas must have focus to send messages issue706
948
1041
  if (!(this.gl.canvas as HTMLCanvasElement).matches(':focus')) {
949
1042
  return
950
1043
  }
951
- const thisMM = this.frac2mm(this.scene.crosshairPos)
952
- // if this.otherNV is an object, then it is a single Niivue instance
953
- if (this.otherNV instanceof Niivue) {
1044
+ for (let i = 0; i < this.otherNV.length; i++) {
1045
+ if (this.otherNV[i] === this) {
1046
+ continue
1047
+ }
1048
+ // gamma
1049
+ if (this.syncOpts.gamma) {
1050
+ this.doSyncGamma(this.otherNV[i])
1051
+ }
1052
+ // crosshair
1053
+ if (this.syncOpts.crosshair) {
1054
+ this.doSyncCrosshair(this.otherNV[i])
1055
+ }
1056
+ // zoomPan
1057
+ if (this.syncOpts.zoomPan) {
1058
+ this.doSyncZoomPan(this.otherNV[i])
1059
+ }
1060
+ // sliceType
1061
+ if (this.syncOpts.sliceType) {
1062
+ this.doSyncSliceType(this.otherNV[i])
1063
+ }
1064
+ // cal_min
1065
+ if (this.syncOpts.cal_min) {
1066
+ this.doSyncCalMin(this.otherNV[i])
1067
+ }
1068
+ // cal_max
1069
+ if (this.syncOpts.cal_max) {
1070
+ this.doSyncCalMax(this.otherNV[i])
1071
+ }
1072
+ // clipPlane
1073
+ if (this.syncOpts.clipPlane) {
1074
+ this.doSyncClipPlane(this.otherNV[i])
1075
+ }
1076
+ // legacy 2d option for multiple properties
954
1077
  if (this.syncOpts['2d']) {
955
- this.otherNV.scene.crosshairPos = this.otherNV.mm2frac(thisMM)
956
- this.otherNV.scene.pan2Dxyzmm = vec4.clone(this.scene.pan2Dxyzmm)
1078
+ this.doSync2d(this.otherNV[i])
957
1079
  }
1080
+ // legacy 3d option for multiple properties
958
1081
  if (this.syncOpts['3d']) {
959
- this.otherNV.scene.renderAzimuth = this.scene.renderAzimuth
960
- this.otherNV.scene.renderElevation = this.scene.renderElevation
961
- this.otherNV.scene.volScaleMultiplier = this.scene.volScaleMultiplier
962
- }
963
- this.otherNV.drawScene()
964
- this.otherNV.createOnLocationChange()
965
- } else if (Array.isArray(this.otherNV)) {
966
- for (let i = 0; i < this.otherNV.length; i++) {
967
- if (this.otherNV[i] === this) {
968
- continue
969
- }
970
- if (this.syncOpts['2d']) {
971
- this.otherNV[i].scene.crosshairPos = this.otherNV[i].mm2frac(thisMM)
972
- this.otherNV[i].scene.pan2Dxyzmm = vec4.clone(this.scene.pan2Dxyzmm)
973
- }
974
- if (this.syncOpts['3d']) {
975
- this.otherNV[i].scene.renderAzimuth = this.scene.renderAzimuth
976
- this.otherNV[i].scene.renderElevation = this.scene.renderElevation
977
- this.otherNV[i].scene.volScaleMultiplier = this.scene.volScaleMultiplier
978
- }
979
- this.otherNV[i].drawScene()
980
- this.otherNV[i].createOnLocationChange()
1082
+ this.doSync3d(this.otherNV[i])
981
1083
  }
1084
+ this.otherNV[i].drawScene()
1085
+ this.otherNV[i].createOnLocationChange()
982
1086
  }
983
1087
  }
984
1088
 
@@ -1088,6 +1192,9 @@ export class Niivue {
1088
1192
  this.drawPenLocation = [NaN, NaN, NaN]
1089
1193
  this.drawPenAxCorSag = -1
1090
1194
  this.uiData.mousedown = true
1195
+ // reset drag positions used previously.
1196
+ this.setDragStart(0, 0)
1197
+ this.setDragEnd(0, 0)
1091
1198
  log.debug('mouse down')
1092
1199
  log.debug(e)
1093
1200
  // record tile where mouse clicked
@@ -1097,8 +1204,16 @@ export class Niivue {
1097
1204
  }
1098
1205
 
1099
1206
  const [x, y] = [pos.x * this.uiData.dpr!, pos.y * this.uiData.dpr!]
1207
+ if (this.opts.clickToSegment) {
1208
+ this.clickToSegmentXY = [x, y]
1209
+ }
1100
1210
  const label = this.getLabelAtPoint([x, y])
1101
1211
  if (label) {
1212
+ // check for user defined onclick handler
1213
+ if (label.onClick) {
1214
+ label.onClick(label)
1215
+ return
1216
+ }
1102
1217
  // find associated mesh
1103
1218
  for (const mesh of this.meshes) {
1104
1219
  if (mesh.type !== MeshType.CONNECTOME) {
@@ -1275,7 +1390,6 @@ export class Niivue {
1275
1390
  const mmLength = vec3.len(v)
1276
1391
  const voxStart = this.frac2vox(fracStart)
1277
1392
  const voxEnd = this.frac2vox(fracEnd)
1278
-
1279
1393
  this.onDragRelease({
1280
1394
  fracStart,
1281
1395
  fracEnd,
@@ -1313,8 +1427,18 @@ export class Niivue {
1313
1427
  if (this.drawPenFillPts.length > 0) {
1314
1428
  this.drawPenFilled()
1315
1429
  } else if (this.drawPenAxCorSag >= 0) {
1316
- this.drawAddUndoBitmap()
1430
+ if (this.opts.clickToSegment) {
1431
+ // copy clickToSegment bitmap to drawBitmap.
1432
+ // determine which bitmap has more non-zero values, indicating it is more recent
1433
+ const sumBitmap = this.sumBitmap(this.drawBitmap)
1434
+ const sumSeg = this.sumBitmap(this.clickToSegmentGrowingBitmap)
1435
+ if (sumSeg > sumBitmap) {
1436
+ this.updateBitmapFromClickToSegment()
1437
+ }
1438
+ }
1317
1439
  }
1440
+ // add the bitmap after the clickToSegment update
1441
+ this.drawAddUndoBitmap()
1318
1442
  this.drawPenLocation = [NaN, NaN, NaN]
1319
1443
  this.drawPenAxCorSag = -1
1320
1444
  if (isFunction(this.onMouseUp)) {
@@ -1328,6 +1452,11 @@ export class Niivue {
1328
1452
  const fracStart = this.canvasPos2frac([this.uiData.dragStart[0], this.uiData.dragStart[1]])
1329
1453
  const fracEnd = this.canvasPos2frac([this.uiData.dragEnd[0], this.uiData.dragEnd[1]])
1330
1454
  this.generateMouseUpCallback(fracStart, fracEnd)
1455
+ // if roiSelection drag mode
1456
+ if (this.opts.dragMode === DRAG_MODE.roiSelection) {
1457
+ // do not call drawScene so that the selection box remains visible
1458
+ return
1459
+ }
1331
1460
  if (this.opts.dragMode !== DRAG_MODE.contrast) {
1332
1461
  return
1333
1462
  }
@@ -1447,6 +1576,16 @@ export class Niivue {
1447
1576
  this.drawScene()
1448
1577
  this.uiData.prevX = this.uiData.currX
1449
1578
  this.uiData.prevY = this.uiData.currY
1579
+ } else if (!this.uiData.mousedown && this.opts.clickToSegment) {
1580
+ const pos = this.getNoPaddingNoBorderCanvasRelativeMousePosition(e, this.gl.canvas)
1581
+ // ignore if mouse moves outside of tile of initial click
1582
+ if (!pos) {
1583
+ return
1584
+ }
1585
+ const x = pos.x * this.uiData.dpr!
1586
+ const y = pos.y * this.uiData.dpr!
1587
+ this.mousePos = [x, y]
1588
+ this.drawScene()
1450
1589
  }
1451
1590
  }
1452
1591
 
@@ -1664,6 +1803,40 @@ export class Niivue {
1664
1803
  if (this.thumbnailVisible) {
1665
1804
  return
1666
1805
  }
1806
+ // check that the user has actually created an ROI already.
1807
+ const dragStartSum = this.uiData.dragStart.reduce((a, b) => a + b, 0)
1808
+ const dragEndSum = this.uiData.dragEnd.reduce((a, b) => a + b, 0)
1809
+ const validDrag = dragStartSum > 0 && dragEndSum > 0
1810
+ // if dragMode is roiSelection, grow or shrink the selection box
1811
+ // by scrolling the mouse wheel. Grows by 1 pixel per scroll
1812
+ if (this.opts.dragMode === DRAG_MODE.roiSelection && validDrag) {
1813
+ const delta = e.deltaY > 0 ? 1 : -1
1814
+ // update the uiData.dragStart and uiData.dragEnd values to grow or shrink the selection box
1815
+ if (this.uiData.dragStart[0] < this.uiData.dragEnd[0]) {
1816
+ this.uiData.dragStart[0] -= delta
1817
+ this.uiData.dragEnd[0] += delta
1818
+ } else {
1819
+ this.uiData.dragStart[0] += delta
1820
+ this.uiData.dragEnd[0] -= delta
1821
+ }
1822
+ if (this.uiData.dragStart[1] < this.uiData.dragEnd[1]) {
1823
+ this.uiData.dragStart[1] -= delta
1824
+ this.uiData.dragEnd[1] += delta
1825
+ } else {
1826
+ this.uiData.dragStart[1] += delta
1827
+ this.uiData.dragEnd[1] -= delta
1828
+ }
1829
+ // draw the scene
1830
+ this.uiData.isDragging = true // set is dragging so the selection box is drawn for this drawScene call
1831
+ this.drawScene() // drawScene uses isDragging to determine if the selection box or ROI should be drawn
1832
+ this.uiData.isDragging = false // reset is dragging
1833
+ const tileIdx = this.tileIndex(this.uiData.dragStart[0], this.uiData.dragStart[1])
1834
+ this.generateMouseUpCallback(
1835
+ this.screenXY2TextureFrac(this.uiData.dragStart[0], this.uiData.dragStart[1], tileIdx),
1836
+ this.screenXY2TextureFrac(this.uiData.dragEnd[0], this.uiData.dragEnd[1], tileIdx)
1837
+ )
1838
+ return
1839
+ }
1667
1840
  const rect = this.canvas!.getBoundingClientRect()
1668
1841
  if (e.deltaY < 0) {
1669
1842
  this.sliceScroll2D(-0.01, e.clientX - rect.left, e.clientY - rect.top)
@@ -2051,7 +2224,7 @@ export class Niivue {
2051
2224
  this.addVolume(volume)
2052
2225
  // set drawing enabled to make sure
2053
2226
  // the new drawing bitmap matches the background volume dims
2054
- this.setDrawingEnabled(true)
2227
+ // this.setDrawingEnabled(true)
2055
2228
  }
2056
2229
  })
2057
2230
  .catch((e) => {
@@ -3262,6 +3435,12 @@ export class Niivue {
3262
3435
 
3263
3436
  // not included in public docs
3264
3437
  sliceScroll2D(posChange: number, x: number, y: number, isDelta = true): void {
3438
+ // check if the canvas has focus
3439
+ if (this.opts.scrollRequiresFocus && this.canvas !== document.activeElement) {
3440
+ log.warn('Canvas element does not have focus. Scroll events will not be processed.')
3441
+ return
3442
+ }
3443
+
3265
3444
  if (this.inGraphTile(x, y)) {
3266
3445
  let vol = this.volumes[0].frame4D
3267
3446
  if (posChange > 0) {
@@ -4005,6 +4184,7 @@ export class Niivue {
4005
4184
  } // something is horribly wrong!
4006
4185
  const vx = this.back.dims[1] * this.back.dims[2] * this.back.dims[3]
4007
4186
  this.drawBitmap = new Uint8Array(vx)
4187
+ this.clickToSegmentGrowingBitmap = new Uint8Array(vx)
4008
4188
  this.drawClearAllUndoBitmaps()
4009
4189
  this.drawAddUndoBitmap()
4010
4190
  this.drawTexture = this.r8Tex(this.drawTexture, TEXTURE7_DRAW, this.back.dims, true)
@@ -4193,6 +4373,29 @@ export class Niivue {
4193
4373
  y = Math.min(Math.max(y, 0), dy - 1)
4194
4374
  z = Math.min(Math.max(z, 0), dz - 1)
4195
4375
  this.drawBitmap![x + y * dx + z * dx * dy] = penValue
4376
+ // get tile index for voxel
4377
+ const isAx = this.drawPenAxCorSag === 0
4378
+ const isCor = this.drawPenAxCorSag === 1
4379
+ const isSag = this.drawPenAxCorSag === 2
4380
+ // since the pen is only drawing in one 2D plane,
4381
+ // only draw the neighbors (based on penSize) in that plane.
4382
+ // if penSize is 1, only draw the voxel itself.
4383
+ // if penSize is even (2, 4, 6, etc.), then the extra voxel will be drawn in the positive direction.
4384
+ // if penSize is odd (3, 5, 7, etc.), then the the pen will be centered on the voxel.
4385
+ if (this.opts.penSize > 1) {
4386
+ const halfPenSize = Math.floor(this.opts.penSize / 2)
4387
+ for (let i = -halfPenSize; i <= halfPenSize; i++) {
4388
+ for (let j = -halfPenSize; j <= halfPenSize; j++) {
4389
+ if (isAx) {
4390
+ this.drawBitmap![x + i + (y + j) * dx + z * dx * dy] = penValue
4391
+ } else if (isCor) {
4392
+ this.drawBitmap![x + i + y * dx + (z + j) * dx * dy] = penValue
4393
+ } else if (isSag) {
4394
+ this.drawBitmap![x + (y + j) * dx + (z + i) * dx * dy] = penValue
4395
+ }
4396
+ }
4397
+ }
4398
+ }
4196
4399
  }
4197
4400
 
4198
4401
  // not included in public docs
@@ -4279,6 +4482,140 @@ export class Niivue {
4279
4482
  }
4280
4483
  }
4281
4484
 
4485
+ /**
4486
+ * Performs a 1-voxel binary dilation on a connected cluster within the drawing mask using the drawFloodFillCore function.
4487
+ *
4488
+ * @param seedXYZ - voxel index of the seed voxel in the mask array.
4489
+ * @param neighbors - Number of neighbors to consider for connectivity and dilation (6, 18, or 26).
4490
+ */
4491
+ drawingBinaryDilationWithSeed(
4492
+ seedXYZ: number[], // seed voxel x,y,z
4493
+ neighbors: 6 | 18 | 26 = 6
4494
+ ): void {
4495
+ try {
4496
+ const mask = this.drawBitmap
4497
+ const xDim = this.back.dims[1]
4498
+ const yDim = this.back.dims[2]
4499
+ const zDim = this.back.dims[3]
4500
+ const nx = xDim
4501
+ const nxy = xDim * yDim
4502
+ const totalVoxels = nxy * zDim
4503
+ function xyz2vx(pt: number[]): number {
4504
+ return pt[0] + pt[1] * nx + pt[2] * nxy
4505
+ }
4506
+
4507
+ const seedIndex = xyz2vx(seedXYZ)
4508
+
4509
+ // check that the seed index is within bounds
4510
+ if (seedIndex < 0 || seedIndex >= totalVoxels) {
4511
+ throw new Error('Seed index is out of bounds.')
4512
+ }
4513
+
4514
+ // get value of the seed voxel
4515
+ const seedValue = mask[seedIndex]
4516
+
4517
+ // check that the seed voxel is filled
4518
+ if (seedValue === 0) {
4519
+ throw new Error('Seed voxel is not part of a filled cluster.')
4520
+ }
4521
+
4522
+ // create a copy of the mask to work on
4523
+ const img = mask.slice()
4524
+ // binarise the img since there could be multiple colors in the mask
4525
+ for (let i = 0; i < totalVoxels; i++) {
4526
+ img[i] = img[i] === seedValue ? 1 : 0
4527
+ }
4528
+
4529
+ // use drawFloodFillCore to identify the connected cluster starting from seedIndex
4530
+ this.drawFloodFillCore(img, seedIndex, neighbors)
4531
+
4532
+ // now, img has the cluster marked with value 2
4533
+ // create an output mask for dilation
4534
+ const outputMask = mask.slice() // Clone the original mask
4535
+
4536
+ // precompute neighbor offsets based on connectivity
4537
+ const neighborOffsets: number[] = []
4538
+
4539
+ // offsets for 6-connectivity (face neighbors)
4540
+ const offsets6 = [-nxy, nxy, -xDim, xDim, -1, 1]
4541
+
4542
+ neighborOffsets.push(...offsets6)
4543
+
4544
+ if (neighbors > 6) {
4545
+ // offsets for 18-connectivity (edge neighbors)
4546
+ neighborOffsets.push(
4547
+ -xDim - 1,
4548
+ -xDim + 1,
4549
+ xDim - 1,
4550
+ xDim + 1,
4551
+ -nxy - xDim,
4552
+ -nxy + xDim,
4553
+ -nxy - 1,
4554
+ -nxy + 1,
4555
+ nxy - xDim,
4556
+ nxy + xDim,
4557
+ nxy - 1,
4558
+ nxy + 1
4559
+ )
4560
+ }
4561
+
4562
+ if (neighbors > 18) {
4563
+ // offsets for 26-connectivity (corner neighbors)
4564
+ neighborOffsets.push(
4565
+ -nxy - xDim - 1,
4566
+ -nxy - xDim + 1,
4567
+ -nxy + xDim - 1,
4568
+ -nxy + xDim + 1,
4569
+ nxy - xDim - 1,
4570
+ nxy - xDim + 1,
4571
+ nxy + xDim - 1,
4572
+ nxy + xDim + 1
4573
+ )
4574
+ }
4575
+
4576
+ // iterate over the cluster voxels (value 2 in img) to perform dilation
4577
+ for (let idx = 0; idx < totalVoxels; idx++) {
4578
+ if (img[idx] === 2) {
4579
+ const x = idx % xDim
4580
+ const y = Math.floor((idx % nxy) / xDim)
4581
+ const z = Math.floor(idx / nxy)
4582
+
4583
+ for (const offset of neighborOffsets) {
4584
+ const neighborIdx = idx + offset
4585
+
4586
+ // skip if neighbor index is out of bounds
4587
+ if (neighborIdx < 0 || neighborIdx >= totalVoxels) {
4588
+ continue
4589
+ }
4590
+
4591
+ // calculate neighbor coordinates
4592
+ const nx = neighborIdx % xDim
4593
+ const ny = Math.floor((neighborIdx % nxy) / xDim)
4594
+ const nz = Math.floor(neighborIdx / nxy)
4595
+
4596
+ // ensure neighbor is adjacent (prevent wrapping around edges)
4597
+ if (Math.abs(nx - x) > 1 || Math.abs(ny - y) > 1 || Math.abs(nz - z) > 1) {
4598
+ continue
4599
+ }
4600
+
4601
+ // if the neighbor voxel is empty in the original mask, fill it in the output mask
4602
+ if (mask[neighborIdx] === 0) {
4603
+ outputMask[neighborIdx] = seedValue
4604
+ }
4605
+ }
4606
+ }
4607
+ }
4608
+ // set the output as the new drawing bitmap
4609
+ this.drawBitmap = outputMask
4610
+ // update the undo stack
4611
+ this.drawAddUndoBitmap()
4612
+ // refresh the drawing (copy from cpu to gpu) and show immediately
4613
+ this.refreshDrawing(true)
4614
+ } catch (error) {
4615
+ log.error('Error in drawingBinaryDilationWithSeed:', error)
4616
+ }
4617
+ }
4618
+
4282
4619
  // a voxel can be defined as having 6, 18 or 26 neighbors:
4283
4620
  // 6: neighbors share faces (distance=1)
4284
4621
  // 18: neighbors share faces (distance=1) or edges (1.4)
@@ -4381,7 +4718,10 @@ export class Niivue {
4381
4718
  growSelectedCluster = 0, // if non-zero, growth based on background intensity POSITIVE_INFINITY for selected or bright, NEGATIVE_INFINITY for selected or darker
4382
4719
  forceMin = NaN,
4383
4720
  forceMax = NaN,
4384
- neighbors = 6
4721
+ neighbors = 6,
4722
+ // option for only flood filling within max distance from seed voxel
4723
+ maxDistanceMM = Number.POSITIVE_INFINITY,
4724
+ is2D = false
4385
4725
  ): void {
4386
4726
  if (!this.drawBitmap) {
4387
4727
  throw new Error('drawBitmap undefined')
@@ -4403,14 +4743,55 @@ export class Niivue {
4403
4743
  const nx = dims[0]
4404
4744
  const nxy = nx * dims[1]
4405
4745
  const nxyz = nxy * dims[2]
4406
- const img = this.drawBitmap.slice()
4746
+ let img = this.drawBitmap.slice()
4747
+ let drawBitmap = this.drawBitmap
4748
+ // use the growing bitmap if the clickToSegmentIsGrowing flag is set.
4749
+ // this allows previewing the flood fill, and the results are copied
4750
+ // to the drawBitmap when the user releases the mouse button.
4751
+ if (this.clickToSegmentIsGrowing) {
4752
+ img = this.clickToSegmentGrowingBitmap.slice()
4753
+ drawBitmap = this.clickToSegmentGrowingBitmap
4754
+ }
4755
+
4407
4756
  if (img.length !== nxy * dims[2]) {
4408
4757
  return
4409
4758
  }
4759
+ // mask2D: axial slices constrained in Z, coronal in Y and sagittal in X
4760
+ let constrainXYZ = -1
4761
+ if (is2D && this.drawPenAxCorSag === SLICE_TYPE.AXIAL) {
4762
+ constrainXYZ = 2
4763
+ } else if (is2D && this.drawPenAxCorSag === SLICE_TYPE.CORONAL) {
4764
+ constrainXYZ = 1
4765
+ } else if (is2D && this.drawPenAxCorSag === SLICE_TYPE.SAGITTAL) {
4766
+ constrainXYZ = 0
4767
+ }
4768
+ function vx2xyz(vx: number): number[] {
4769
+ // provided address in 1D array, return XYZ coordinate
4770
+ const Z = Math.floor(vx / nxy) // slice
4771
+ const Y = Math.floor((vx - Z * nxy) / nx) // column
4772
+ const X = Math.floor(vx % nx)
4773
+ return [X, Y, Z]
4774
+ }
4410
4775
  function xyz2vx(pt: number[]): number {
4411
4776
  // provided an XYZ 3D point, provide address in 1D array
4412
4777
  return pt[0] + pt[1] * nx + pt[2] * nxy
4413
4778
  }
4779
+ const vx2mm = (xyz: number[]): vec3 => {
4780
+ return this.vox2mm(xyz, this.back.matRAS)
4781
+ }
4782
+ // store seed vox as mm coordinates
4783
+ const seedMM = vx2mm(seedXYZ)
4784
+ const maxDistanceMM2 = maxDistanceMM ** 2
4785
+ // function to check if new point to be checked is less than maxDistanceMM
4786
+ function isWithinDistance(vx: number): boolean {
4787
+ const xzyVox = vx2xyz(vx)
4788
+ if (constrainXYZ >= 0 && xzyVox[constrainXYZ] !== seedXYZ[constrainXYZ]) {
4789
+ return false
4790
+ }
4791
+ const xyzMM = vx2mm(xzyVox)
4792
+ const dist2 = (xyzMM[0] - seedMM[0]) ** 2 + (xyzMM[1] - seedMM[1]) ** 2 + (xyzMM[2] - seedMM[2]) ** 2
4793
+ return dist2 <= maxDistanceMM2
4794
+ }
4414
4795
  const seedVx = xyz2vx(seedXYZ)
4415
4796
  const seedColor = img[seedVx]
4416
4797
  if (seedColor === newColor) {
@@ -4423,7 +4804,13 @@ export class Niivue {
4423
4804
  }
4424
4805
  for (let i = 1; i < nxyz; i++) {
4425
4806
  img[i] = 0
4426
- if (this.drawBitmap[i] === seedColor) {
4807
+ if (drawBitmap[i] === seedColor) {
4808
+ // check if voxel index i is within maxDistanceMM from seed voxel
4809
+ if (!isWithinDistance(i)) {
4810
+ // move to next voxel if not within distance,
4811
+ // no need to check voxel intensity for cluster assignment
4812
+ continue
4813
+ }
4427
4814
  img[i] = 1
4428
4815
  }
4429
4816
  }
@@ -4455,6 +4842,12 @@ export class Niivue {
4455
4842
  for (let i = 1; i < nxyz; i++) {
4456
4843
  img[i] = 0
4457
4844
  if (backImg[i] >= mn && backImg[i] <= mx) {
4845
+ // check if voxel index i is within maxDistanceMM from seed voxel
4846
+ if (!isWithinDistance(i)) {
4847
+ // move to next voxel if not within distance,
4848
+ // no need to check voxel intensity for cluster assignment
4849
+ continue
4850
+ }
4458
4851
  img[i] = 1
4459
4852
  }
4460
4853
  }
@@ -4465,11 +4858,22 @@ export class Niivue {
4465
4858
  for (let i = 1; i < nxyz; i++) {
4466
4859
  if (img[i] === 2) {
4467
4860
  // if part of cluster
4468
- this.drawBitmap[i] = newColor
4861
+ drawBitmap[i] = newColor
4469
4862
  }
4470
4863
  }
4471
- this.drawAddUndoBitmap()
4472
- this.refreshDrawing(false)
4864
+ if (!this.clickToSegmentIsGrowing) {
4865
+ this.drawBitmap = drawBitmap.slice()
4866
+ if (!this.opts.clickToSegment) {
4867
+ // only update the undo array if clickToSegment is not enabled.
4868
+ // This avoids adding the same drawing twice, since the mouse up
4869
+ // event will also add the drawing to the undo array.
4870
+ this.drawAddUndoBitmap()
4871
+ }
4872
+ this.refreshDrawing(true, this.clickToSegmentIsGrowing)
4873
+ } else {
4874
+ this.clickToSegmentGrowingBitmap = drawBitmap
4875
+ this.refreshDrawing(true, this.clickToSegmentIsGrowing)
4876
+ }
4473
4877
  }
4474
4878
 
4475
4879
  // not included in public docs
@@ -4656,6 +5060,7 @@ export class Niivue {
4656
5060
  this.drawClearAllUndoBitmaps()
4657
5061
  this.rgbaTex(this.drawTexture, TEXTURE7_DRAW, [2, 2, 2, 2], true)
4658
5062
  this.drawBitmap = null
5063
+ this.clickToSegmentGrowingBitmap = null
4659
5064
  this.drawScene()
4660
5065
  }
4661
5066
 
@@ -4665,7 +5070,7 @@ export class Niivue {
4665
5070
  * @example niivue.refreshDrawing();
4666
5071
  * @see {@link https://niivue.github.io/niivue/features/cactus.html | live demo usage}
4667
5072
  */
4668
- refreshDrawing(isForceRedraw = true): void {
5073
+ refreshDrawing(isForceRedraw = true, useClickToSegmentBitmap = false): void {
4669
5074
  if (!this.back?.dims) {
4670
5075
  throw new Error('back.dims undefined')
4671
5076
  }
@@ -4695,7 +5100,7 @@ export class Niivue {
4695
5100
  dims[3],
4696
5101
  this.gl.RED,
4697
5102
  this.gl.UNSIGNED_BYTE,
4698
- this.drawBitmap
5103
+ useClickToSegmentBitmap ? this.clickToSegmentGrowingBitmap : this.drawBitmap
4699
5104
  )
4700
5105
  if (isForceRedraw) {
4701
5106
  this.drawScene()
@@ -5163,6 +5568,8 @@ export class Niivue {
5163
5568
  // rect shader (crosshair): horizontal and vertical lines only
5164
5569
  this.rectShader = new Shader(gl, vertRectShader, fragRectShader)
5165
5570
  this.rectShader.use(gl)
5571
+ this.rectOutlineShader = new Shader(gl, vertRectShader, fragRectOutlineShader)
5572
+ this.rectOutlineShader.use(gl)
5166
5573
  // line shader: diagonal lines
5167
5574
  this.lineShader = new Shader(gl, vertLineShader, fragRectShader)
5168
5575
  this.lineShader.use(gl)
@@ -5338,14 +5745,46 @@ export class Niivue {
5338
5745
 
5339
5746
  /**
5340
5747
  * basic statistics for selected voxel-based image
5341
- * @param layer - selects image to describe
5342
- * @param masks - are optional binary images to filter voxles
5343
- * @returns numeric values to describe image
5344
- * @example niivue.getDescriptives(0);
5748
+ * @param options - an object containing the following properties:
5749
+ * - layer: selects image to describe
5750
+ * - masks: optional binary images to filter voxels
5751
+ * - drawingIsMask: a boolean indicating if the drawing is used as a mask
5752
+ * - roiIsMask: a boolean indicating if the ROI is used as a mask
5753
+ * - startVox: the starting voxel coordinates
5754
+ * - endVox: the ending voxel coordinates
5755
+ * @returns numeric values to describe image or regions of images
5756
+ * @example
5757
+ * niivue.getDescriptives({
5758
+ * layer: 0,
5759
+ * masks: [],
5760
+ * drawingIsMask: true, // drawingIsMask and roiIsMask are mutually exclusive
5761
+ * roiIsMask: false,
5762
+ * startVox: [10, 20, 30], // ignored if roiIsMask is false
5763
+ * endVox: [40, 50, 60] // ignored if roiIsMask is false
5764
+ * });
5345
5765
  * @see {@link https://niivue.github.io/niivue/features/draw2.html | live demo usage}
5346
5766
  */
5347
- getDescriptives(layer = 0, masks = [], drawingIsMask = false): Descriptive {
5767
+ getDescriptives(options: {
5768
+ layer?: number
5769
+ masks?: number[]
5770
+ drawingIsMask?: boolean
5771
+ roiIsMask?: boolean
5772
+ startVox?: number[]
5773
+ endVox?: number[]
5774
+ }): Descriptive {
5775
+ const {
5776
+ layer = 0,
5777
+ masks = [],
5778
+ drawingIsMask = false,
5779
+ roiIsMask = false,
5780
+ startVox = [0, 0, 0],
5781
+ endVox = [0, 0, 0]
5782
+ } = options
5783
+
5784
+ // Rest of the code remains the same
5785
+ let area = null // used if roiIsMask since ROI is in 2D slice
5348
5786
  const hdr = this.volumes[layer].hdr!
5787
+ const pixDimsRAS = this.volumes[layer].pixDimsRAS!
5349
5788
  let slope = hdr.scl_slope
5350
5789
  if (isNaN(slope)) {
5351
5790
  slope = 1
@@ -5384,6 +5823,104 @@ export class Niivue {
5384
5823
  mask[i] = 0
5385
5824
  }
5386
5825
  }
5826
+ } else if (masks.length < 1 && roiIsMask) {
5827
+ // fill mask with zeros
5828
+ mask.fill(0)
5829
+ console.log('startVox', startVox)
5830
+ console.log('endVox', endVox)
5831
+
5832
+ // identify the constant dimension (the plane where the ellipse is drawn)
5833
+ let constantDim = -1
5834
+ if (startVox[0] === endVox[0]) {
5835
+ constantDim = 0 // x is constant
5836
+ } else if (startVox[1] === endVox[1]) {
5837
+ constantDim = 1 // y is constant
5838
+ } else if (startVox[2] === endVox[2]) {
5839
+ constantDim = 2 // z is constant
5840
+ } else {
5841
+ console.error('Error: No constant dimension found.')
5842
+ return
5843
+ }
5844
+
5845
+ // get the varying dimensions
5846
+ const dims = [0, 1, 2]
5847
+ const varDims = dims.filter((dim) => dim !== constantDim)
5848
+
5849
+ // compute the center of the ellipse in voxel coordinates
5850
+ const centerVox = []
5851
+ centerVox[constantDim] = startVox[constantDim]
5852
+ centerVox[varDims[0]] = (startVox[varDims[0]] + endVox[varDims[0]]) / 2
5853
+ centerVox[varDims[1]] = (startVox[varDims[1]] + endVox[varDims[1]]) / 2
5854
+
5855
+ // compute the radii along each varying dimension
5856
+ const radiusX = Math.abs(endVox[varDims[0]] - startVox[varDims[0]]) / 2
5857
+ const radiusY = Math.abs(endVox[varDims[1]] - startVox[varDims[1]]) / 2
5858
+
5859
+ // dimensions of the image
5860
+ const xdim = hdr.dims[1]
5861
+ const ydim = hdr.dims[2]
5862
+ // const zdim = hdr.dims[3]
5863
+
5864
+ // define the ranges for the varying dimensions
5865
+ const minVarDim0 = Math.max(0, Math.floor(centerVox[varDims[0]] - radiusX))
5866
+ const maxVarDim0 = Math.min(hdr.dims[varDims[0] + 1] - 1, Math.ceil(centerVox[varDims[0]] + radiusX))
5867
+
5868
+ const minVarDim1 = Math.max(0, Math.floor(centerVox[varDims[1]] - radiusY))
5869
+ const maxVarDim1 = Math.min(hdr.dims[varDims[1] + 1] - 1, Math.ceil(centerVox[varDims[1]] + radiusY))
5870
+
5871
+ // the constant dimension value
5872
+ const constDimVal = centerVox[constantDim]
5873
+ if (constDimVal < 0 || constDimVal >= hdr.dims[constantDim + 1]) {
5874
+ console.error('Error: Constant dimension value is out of bounds.')
5875
+ return
5876
+ }
5877
+
5878
+ // iterate over the varying dimensions and apply the elliptical mask
5879
+ for (let i = minVarDim0; i <= maxVarDim0; i++) {
5880
+ for (let j = minVarDim1; j <= maxVarDim1; j++) {
5881
+ // set the voxel coordinates
5882
+ const voxel = []
5883
+ voxel[constantDim] = constDimVal // Fixed dimension
5884
+ voxel[varDims[0]] = i
5885
+ voxel[varDims[1]] = j
5886
+ // calculate the normalized distances from the center
5887
+ const di = (voxel[varDims[0]] - centerVox[varDims[0]]) / radiusX
5888
+ const dj = (voxel[varDims[1]] - centerVox[varDims[1]]) / radiusY
5889
+ // calculate the squared distance in ellipse space
5890
+ const distSq = di * di + dj * dj
5891
+ // check if the voxel is within the ellipse
5892
+ if (distSq <= 1) {
5893
+ // calculate the index in the mask array
5894
+ const x = voxel[0]
5895
+ const y = voxel[1]
5896
+ const z = voxel[2]
5897
+ const index = z * xdim * ydim + y * xdim + x
5898
+ mask[index] = 1
5899
+ }
5900
+ }
5901
+ }
5902
+ // calculate the area based on the number of voxels in the mask
5903
+ const voxelArea = pixDimsRAS[varDims[0] + 1] * pixDimsRAS[varDims[1] + 1] // adjusted for 1-indexing
5904
+ const numMaskedVoxels = mask.reduce((count, value) => count + (value === 1 ? 1 : 0), 0)
5905
+ area = numMaskedVoxels * voxelArea
5906
+
5907
+ // perhaps better to calculate the area using the ellipse area formula
5908
+ const radiusX_mm = radiusX * pixDimsRAS[varDims[0] + 1]
5909
+ const radiusY_mm = radiusY * pixDimsRAS[varDims[1] + 1]
5910
+ const areaEllipse = Math.PI * radiusX_mm * radiusY_mm
5911
+ area = areaEllipse
5912
+ // for debuging: show mask -- loop over drawing and set drawing to 1 if mask is 1
5913
+ // this.setDrawingEnabled(true)
5914
+ // this.drawOpacity = 0.3
5915
+ // for (let i = 0; i < nv; i++) {
5916
+ // if (mask[i] === 1) {
5917
+ // this.drawBitmap![i] = 1
5918
+ // } else {
5919
+ // this.drawBitmap![i] = 0
5920
+ // }
5921
+ // }
5922
+ // this.refreshDrawing(false)
5923
+ // this.setDrawingEnabled(false)
5387
5924
  }
5388
5925
  // Welfords method
5389
5926
  // https://www.embeddedrelated.com/showarticle/785.php
@@ -5444,7 +5981,8 @@ export class Niivue {
5444
5981
  cal_min: this.volumes[layer].cal_min!,
5445
5982
  cal_max: this.volumes[layer].cal_max!,
5446
5983
  robust_min: this.volumes[layer].robust_min!,
5447
- robust_max: this.volumes[layer].robust_max!
5984
+ robust_max: this.volumes[layer].robust_max!,
5985
+ area
5448
5986
  }
5449
5987
  }
5450
5988
 
@@ -6796,6 +7334,7 @@ export class Niivue {
6796
7334
  * @see {@link https://niivue.github.io/niivue/features/colormaps.html | live demo usage}
6797
7335
  */
6798
7336
  setGamma(gamma = 1.0): void {
7337
+ this.scene.gamma = gamma
6799
7338
  cmapper.gamma = gamma
6800
7339
  this.updateGLVolume()
6801
7340
  }
@@ -7114,6 +7653,31 @@ export class Niivue {
7114
7653
  return pos[0] > 0 && pos[1] > 0 && pos[0] <= 1 && pos[1] <= 1
7115
7654
  }
7116
7655
 
7656
+ // update drawBitmap if it differs from clickTosegmentBitmap
7657
+ updateBitmapFromClickToSegment(): void {
7658
+ if (this.clickToSegmentGrowingBitmap === null) {
7659
+ return
7660
+ }
7661
+ if (this.drawBitmap === null) {
7662
+ return
7663
+ }
7664
+ if (this.clickToSegmentGrowingBitmap.length !== this.drawBitmap.length) {
7665
+ return
7666
+ }
7667
+ const nvx = this.drawBitmap.length
7668
+ for (let i = 0; i < nvx; i++) {
7669
+ this.drawBitmap[i] = this.clickToSegmentGrowingBitmap[i]
7670
+ }
7671
+ }
7672
+
7673
+ sumBitmap(img: Uint8Array): number {
7674
+ let sum = 0
7675
+ for (let i = 0; i < img.length; i++) {
7676
+ sum += img[i]
7677
+ }
7678
+ return sum
7679
+ }
7680
+
7117
7681
  // not included in public docs
7118
7682
  // handle mouse click event on canvas
7119
7683
  mouseClick(x: number, y: number, posChange = 0, isDelta = true): void {
@@ -7177,7 +7741,7 @@ export class Niivue {
7177
7741
  if (axCorSag > SLICE_TYPE.SAGITTAL) {
7178
7742
  continue
7179
7743
  }
7180
- const texFrac = this.screenXY2TextureFrac(x, y, i, false)
7744
+ let texFrac = this.screenXY2TextureFrac(x, y, i, false)
7181
7745
  if (texFrac[0] < 0) {
7182
7746
  continue
7183
7747
  } // click not on slice i
@@ -7207,34 +7771,102 @@ export class Niivue {
7207
7771
  this.scene.crosshairPos = vec3.clone(texFrac)
7208
7772
  }
7209
7773
  if (this.opts.drawingEnabled) {
7210
- const pt = this.frac2vox(this.scene.crosshairPos) as [number, number, number]
7774
+ // drawing done in voxels
7775
+ let pt = this.frac2vox(this.scene.crosshairPos) as [number, number, number]
7211
7776
  // if click-to-segment enabled
7212
7777
  if (this.opts.clickToSegment) {
7213
- const radius = this.opts.clickToSegmentRadius
7214
- const steps = this.opts.clickToSegmentSteps
7778
+ texFrac = this.screenXY2TextureFrac(this.clickToSegmentXY[0], this.clickToSegmentXY[1], i, false)
7779
+ pt = this.frac2vox(texFrac) as [number, number, number]
7780
+ let diff = 0
7781
+ let threshold = this.opts.clickToSegmentPercent + diff
7782
+ if (this.uiData.mousedown) {
7783
+ // get percent difference between current x, y, and clickToSegmentXY
7784
+ const xDiff = (this.clickToSegmentXY[0] - x) / this.gl.canvas.width
7785
+ const yDiff = (this.clickToSegmentXY[1] - y) / this.gl.canvas.height
7786
+ diff = Math.max(Math.abs(xDiff), Math.abs(yDiff))
7787
+ // use the sign of the larger difference to determine the direction
7788
+ diff *= Math.sign(Math.abs(xDiff) > Math.abs(yDiff) ? xDiff : yDiff)
7789
+ threshold = this.opts.clickToSegmentPercent + diff
7790
+ }
7791
+ // get voxel value of pt
7792
+ let voxelIntensity = this.back.getValue(pt[0], pt[1], pt[2])
7793
+ // if clickToSegmentAutoIntensity, then calculate if we need to flood fill
7794
+ // in a bright or dark region based on the intensity of the clicked voxel,
7795
+ // and where it falls in the range of cal_min and cal_max.
7796
+ // !important! If this option is true, then it will ignore the boolean value of
7797
+ // clickToSegmentBright supplied by the user
7798
+ if (this.opts.clickToSegmentAutoIntensity) {
7799
+ if (threshold !== 0) {
7800
+ if (voxelIntensity === 0) {
7801
+ voxelIntensity = 0.01
7802
+ }
7803
+ this.opts.clickToSegmentIntensityMax = voxelIntensity * (1 + threshold)
7804
+ this.opts.clickToSegmentIntensityMin = voxelIntensity * (1 - threshold)
7805
+ }
7806
+ // if voxel intensity is greater than the midpoint of cal_min and cal_max,
7807
+ // then flood fill in a bright region
7808
+ if (voxelIntensity > (this.back.cal_min + this.back.cal_max) * 0.5) {
7809
+ this.opts.clickToSegmentBright = true
7810
+ } else {
7811
+ // else flood fill in a dark region
7812
+ this.opts.clickToSegmentBright = false
7813
+ }
7814
+ }
7815
+ // set brightOrDark now, if clickToSegmentAutoBrightOrDark is false,
7816
+ // then brightOrDark will be set to the value of clickToSegmentBright supplied by the user
7215
7817
  const brightOrDark = this.opts.clickToSegmentBright ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY
7216
- this.drawPenFillPts = []
7217
7818
  this.drawPenAxCorSag = axCorSag
7218
- for (let i = 1; i <= steps; i++) {
7219
- const angle = (i / steps) * 2 * Math.PI
7220
- let xVox = pt[0] + radius * Math.cos(angle)
7221
- let yVox = pt[1] + radius * Math.sin(angle)
7222
- const zVox = pt[2]
7223
- xVox = Math.round(xVox)
7224
- yVox = Math.round(yVox)
7225
- this.drawPt(xVox, yVox, zVox, this.opts.penValue)
7226
- this.drawPenFillPts.push([xVox, yVox, pt[2]])
7227
- // fill in the circle if we are at the last step.
7228
- // This also triggers the growth of the circle based on cluster intensity method of flood fill.
7229
- // If the circle is drawn in a bright region, it will grow in the bright region using all connected bright voxels and vice versa.
7230
- if (i === steps) {
7231
- this.drawFloodFill([xVox, yVox, pt[2]], 0, brightOrDark, NaN, NaN, this.opts.floodFillNeighbors)
7232
- }
7819
+ if (this.drawPenAxCorSag === SLICE_TYPE.AXIAL) {
7820
+ // get voxel penSize for the current slice, but what if pixDims are not isotropic?
7821
+ // we will use the smallest pixDim for the current slice
7822
+ const pixDims = [this.back.pixDimsRAS[0], this.back.pixDimsRAS[1]]
7823
+ const minPixDim = Math.min(...pixDims)
7824
+ this.opts.penSize = Math.ceil(this.opts.clickToSegmentRadius / minPixDim)
7825
+ } else if (this.drawPenAxCorSag === SLICE_TYPE.CORONAL) {
7826
+ const pixDims = [this.back.pixDimsRAS[0], this.back.pixDimsRAS[2]]
7827
+ const minPixDim = Math.min(...pixDims)
7828
+ this.opts.penSize = Math.ceil(this.opts.clickToSegmentRadius / minPixDim)
7829
+ } else if (this.drawPenAxCorSag === SLICE_TYPE.SAGITTAL) {
7830
+ const pixDims = [this.back.pixDimsRAS[1], this.back.pixDimsRAS[2]]
7831
+ const minPixDim = Math.min(...pixDims)
7832
+ this.opts.penSize = Math.ceil(this.opts.clickToSegmentRadius / minPixDim)
7833
+ }
7834
+ // draw the point at the cursor using the penSize to set the
7835
+ // seed for the flood fill
7836
+ this.drawPt(pt[0], pt[1], pt[2], this.opts.penValue)
7837
+
7838
+ if (diff !== 0) {
7839
+ this.clickToSegmentIsGrowing = true
7840
+ this.clickToSegmentGrowingBitmap = this.drawBitmap.slice()
7841
+ } else {
7842
+ this.clickToSegmentIsGrowing = false
7233
7843
  }
7844
+
7845
+ // do flood fill
7846
+ this.drawFloodFill(
7847
+ [pt[0], pt[1], pt[2]],
7848
+ 0,
7849
+ brightOrDark,
7850
+ this.opts.clickToSegmentIntensityMin,
7851
+ this.opts.clickToSegmentIntensityMax,
7852
+ this.opts.floodFillNeighbors,
7853
+ this.opts.clickToSegmentMaxDistanceMM,
7854
+ this.opts.clickToSegmentIs2D
7855
+ )
7856
+ // draw the scene so we see the changes
7234
7857
  this.drawScene()
7235
7858
  this.createOnLocationChange(axCorSag)
7236
- // get the volume of the segmented region
7237
- const info = this.getDescriptives(0, [], true)
7859
+ if (this.clickToSegmentIsGrowing) {
7860
+ // don't get descriptive stats if the user
7861
+ // is still growing the segmented region
7862
+ return
7863
+ }
7864
+ // get the volume of the segmented region if the user is not growing the region
7865
+ const info = this.getDescriptives({
7866
+ layer: 0,
7867
+ masks: [],
7868
+ drawingIsMask: true
7869
+ })
7238
7870
  this.onClickToSegment({ mL: info.volumeML, mm3: info.volumeMM3 })
7239
7871
  return
7240
7872
  }
@@ -7486,20 +8118,44 @@ export class Niivue {
7486
8118
  if (!this.rectShader) {
7487
8119
  throw new Error('rectShader undefined')
7488
8120
  }
7489
- this.rectShader.use(this.gl)
7490
- this.gl.enable(this.gl.BLEND)
7491
- this.gl.uniform4fv(this.rectShader.uniforms.lineColor, lineColor)
7492
- this.gl.uniform2fv(this.rectShader.uniforms.canvasWidthHeight, [this.gl.canvas.width, this.gl.canvas.height])
7493
- this.gl.uniform4f(
7494
- this.rectShader.uniforms.leftTopWidthHeight,
7495
- leftTopWidthHeight[0],
7496
- leftTopWidthHeight[1],
7497
- leftTopWidthHeight[2],
7498
- leftTopWidthHeight[3]
7499
- )
7500
- this.gl.bindVertexArray(this.genericVAO)
7501
- this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4)
7502
- this.gl.bindVertexArray(this.unusedVAO) // switch off to avoid tampering with settings
8121
+ if (!this.opts.selectionBoxIsOutline) {
8122
+ this.rectShader.use(this.gl)
8123
+ this.gl.enable(this.gl.BLEND)
8124
+ this.gl.uniform4fv(this.rectShader.uniforms.lineColor, lineColor)
8125
+ this.gl.uniform2fv(this.rectShader.uniforms.canvasWidthHeight, [this.gl.canvas.width, this.gl.canvas.height])
8126
+ this.gl.uniform4f(
8127
+ this.rectShader.uniforms.leftTopWidthHeight,
8128
+ leftTopWidthHeight[0],
8129
+ leftTopWidthHeight[1],
8130
+ leftTopWidthHeight[2],
8131
+ leftTopWidthHeight[3]
8132
+ )
8133
+ this.gl.bindVertexArray(this.genericVAO)
8134
+ this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4)
8135
+ this.gl.bindVertexArray(this.unusedVAO) // switch off to avoid tampering with settings
8136
+ } else {
8137
+ this.drawCircle(leftTopWidthHeight, lineColor, 0.1)
8138
+ // this.opts.selectionBoxIsOutline == true
8139
+ this.rectOutlineShader.use(this.gl)
8140
+ this.gl.enable(this.gl.BLEND)
8141
+ // set thickness of line
8142
+ this.gl.uniform1f(this.rectOutlineShader.uniforms.thickness, this.opts.selectionBoxLineThickness)
8143
+ this.gl.uniform4fv(this.rectOutlineShader.uniforms.lineColor, lineColor)
8144
+ this.gl.uniform2fv(this.rectOutlineShader.uniforms.canvasWidthHeight, [
8145
+ this.gl.canvas.width,
8146
+ this.gl.canvas.height
8147
+ ])
8148
+ this.gl.uniform4f(
8149
+ this.rectOutlineShader.uniforms.leftTopWidthHeight,
8150
+ leftTopWidthHeight[0],
8151
+ leftTopWidthHeight[1],
8152
+ leftTopWidthHeight[2],
8153
+ leftTopWidthHeight[3]
8154
+ )
8155
+ this.gl.bindVertexArray(this.genericVAO)
8156
+ this.gl.drawArrays(this.gl.TRIANGLE_STRIP, 0, 4)
8157
+ this.gl.bindVertexArray(this.unusedVAO) // switch off to avoid tampering with settings
8158
+ }
7503
8159
  }
7504
8160
 
7505
8161
  drawCircle(leftTopWidthHeight: number[], circleColor = this.opts.fontColor, fillPercent = 1.0): void {
@@ -7527,6 +8183,11 @@ export class Niivue {
7527
8183
  // not included in public docs
7528
8184
  // draw a rectangle at desired location
7529
8185
  drawSelectionBox(leftTopWidthHeight: number[]): void {
8186
+ if (this.opts.dragMode === DRAG_MODE.roiSelection) {
8187
+ this.drawCircle(leftTopWidthHeight, this.opts.selectionBoxColor, 0.1)
8188
+ return
8189
+ }
8190
+ // else draw a rectangle
7530
8191
  this.drawRect(leftTopWidthHeight, this.opts.selectionBoxColor)
7531
8192
  }
7532
8193
 
@@ -7551,9 +8212,26 @@ export class Niivue {
7551
8212
  return labels
7552
8213
  }
7553
8214
 
8215
+ getConnectomeLabels(): NVLabel3D[] {
8216
+ const connectomes = this.meshes.filter((m) => m.type === MeshType.CONNECTOME)
8217
+ const meshNodes = connectomes.flatMap((m) => m.nodes as NVConnectomeNode[])
8218
+ const meshLabels = meshNodes.map((n) => n.label)
8219
+ // filter our undefined labels
8220
+ const definedMeshLabels = meshLabels.filter((l): l is NVLabel3D => l !== undefined)
8221
+ // get all of our non-anchored labels
8222
+ const nonAnchoredLabels = this.document.labels.filter((l) => l.anchor == null || l.anchor === LabelAnchorPoint.NONE)
8223
+ // get the unique set of unanchored labels
8224
+ const nonAnchoredLabelSet = new Set(definedMeshLabels)
8225
+ for (const label of nonAnchoredLabels) {
8226
+ nonAnchoredLabelSet.add(label)
8227
+ }
8228
+
8229
+ return Array.from(nonAnchoredLabelSet)
8230
+ }
8231
+
7554
8232
  getBulletMarginWidth(): number {
7555
8233
  let bulletMargin = 0
7556
- const labels = this.getAllLabels()
8234
+ const labels = this.getConnectomeLabels()
7557
8235
  if (labels.length === 0) {
7558
8236
  return 0
7559
8237
  }
@@ -7578,7 +8256,7 @@ export class Niivue {
7578
8256
  }
7579
8257
 
7580
8258
  getLegendPanelWidth(): number {
7581
- const labels = this.getAllLabels()
8259
+ const labels = this.getConnectomeLabels()
7582
8260
  if (!this.opts.showLegend || labels.length === 0) {
7583
8261
  return 0
7584
8262
  }
@@ -7609,7 +8287,7 @@ export class Niivue {
7609
8287
  }
7610
8288
 
7611
8289
  getLegendPanelHeight(): number {
7612
- const labels = this.getAllLabels()
8290
+ const labels = this.getConnectomeLabels()
7613
8291
  let height = 0
7614
8292
  const scale = 1.0 // we may want to make this adjustable in the future
7615
8293
  const verticalMargin = this.opts.textHeight * this.gl.canvas.height * scale
@@ -8124,7 +8802,11 @@ export class Niivue {
8124
8802
 
8125
8803
  // not included in public docs
8126
8804
  // show text labels for L/R, A/P, I/S dimensions
8127
- drawSliceOrientationText(leftTopWidthHeight: number[], axCorSag: SLICE_TYPE): void {
8805
+ drawSliceOrientationText(
8806
+ leftTopWidthHeight: number[],
8807
+ axCorSag: SLICE_TYPE,
8808
+ padLeftTop: number[] = [NaN, NaN]
8809
+ ): void {
8128
8810
  this.gl.viewport(0, 0, this.gl.canvas.width, this.gl.canvas.height)
8129
8811
  let topText = 'S'
8130
8812
  if (axCorSag === SLICE_TYPE.AXIAL) {
@@ -8138,9 +8820,32 @@ export class Niivue {
8138
8820
  this.drawTextRightBelow([leftTopWidthHeight[0], leftTopWidthHeight[1]], leftText + topText)
8139
8821
  return
8140
8822
  }
8141
- this.drawTextBelow([leftTopWidthHeight[0] + leftTopWidthHeight[2] * 0.5, leftTopWidthHeight[1]], topText)
8142
-
8143
- this.drawTextRight([leftTopWidthHeight[0], leftTopWidthHeight[1] + leftTopWidthHeight[3] * 0.5], leftText)
8823
+ let drawBelow = true
8824
+ let drawRight = true
8825
+ if (!isNaN(padLeftTop[0])) {
8826
+ const ht = this.opts.textHeight * this.gl.canvas.height + 2
8827
+ if (padLeftTop[1] > ht) {
8828
+ this.drawTextBelow(
8829
+ [leftTopWidthHeight[0] + leftTopWidthHeight[2] * 0.5, leftTopWidthHeight[1] + padLeftTop[1] - ht],
8830
+ topText
8831
+ )
8832
+ drawBelow = false
8833
+ }
8834
+ const wid = this.textWidth(ht, leftText) + 2
8835
+ if (padLeftTop[0] > wid) {
8836
+ this.drawTextRight(
8837
+ [leftTopWidthHeight[0] + padLeftTop[0] - wid, leftTopWidthHeight[1] + leftTopWidthHeight[3] * 0.5],
8838
+ leftText
8839
+ )
8840
+ drawRight = false
8841
+ }
8842
+ }
8843
+ if (drawBelow) {
8844
+ this.drawTextBelow([leftTopWidthHeight[0] + leftTopWidthHeight[2] * 0.5, leftTopWidthHeight[1]], topText)
8845
+ }
8846
+ if (drawRight) {
8847
+ this.drawTextRight([leftTopWidthHeight[0], leftTopWidthHeight[1] + leftTopWidthHeight[3] * 0.5], leftText)
8848
+ }
8144
8849
  }
8145
8850
 
8146
8851
  // not included in public docs
@@ -8183,7 +8888,7 @@ export class Niivue {
8183
8888
 
8184
8889
  // not included in public docs
8185
8890
  // draw 2D tile
8186
- draw2D(leftTopWidthHeight: number[], axCorSag: SLICE_TYPE, customMM = NaN): void {
8891
+ draw2DMain(leftTopWidthHeight: number[], axCorSag: SLICE_TYPE, customMM = NaN): void {
8187
8892
  let frac2mmTexture = this.volumes[0].frac2mm!.slice()
8188
8893
  let screen = this.screenFieldOfViewExtendedMM(axCorSag)
8189
8894
  let mesh2ortho = mat4.create()
@@ -8212,8 +8917,10 @@ export class Niivue {
8212
8917
  elevation = isRadiolgical ? -90 : 90
8213
8918
  }
8214
8919
  const gl = this.gl
8920
+ let isStretchToScreen = false
8215
8921
  if (leftTopWidthHeight[2] === 0 || leftTopWidthHeight[3] === 0) {
8216
8922
  // only one tile: stretch tile to fill whole screen.
8923
+ isStretchToScreen = true
8217
8924
  const pixPerMMw = gl.canvas.width / screen.fovMM[0]
8218
8925
  const pixPerMMh = gl.canvas.height / screen.fovMM[1]
8219
8926
  const pixPerMMmin = Math.min(pixPerMMw, pixPerMMh)
@@ -8372,10 +9079,42 @@ export class Niivue {
8372
9079
  // no crossbars for mosaic view
8373
9080
  this.drawCrosshairs3D(false, 0.15, obj.modelViewProjectionMatrix, true, this.opts.isSliceMM)
8374
9081
  }
8375
- this.drawSliceOrientationText(leftTopWidthHeight, axCorSag)
9082
+ if (isStretchToScreen) {
9083
+ // issue1065
9084
+ this.drawSliceOrientationText(leftTopWidthHeight, axCorSag)
9085
+ }
8376
9086
  this.readyForSync = true
8377
9087
  }
8378
9088
 
9089
+ draw2D(
9090
+ leftTopWidthHeight: number[],
9091
+ axCorSag: SLICE_TYPE,
9092
+ customMM = NaN,
9093
+ imageWidthHeight: number[] = [NaN, NaN]
9094
+ ): void {
9095
+ const padLeftTop = [NaN, NaN]
9096
+ if (isNaN(imageWidthHeight[0])) {
9097
+ this.draw2DMain(leftTopWidthHeight, axCorSag, customMM)
9098
+ } else {
9099
+ // inset as padded in tile
9100
+ const ltwh = leftTopWidthHeight.slice()
9101
+ padLeftTop[0] = Math.floor(0.5 * (ltwh[2] - imageWidthHeight[0]))
9102
+ padLeftTop[1] = Math.floor(0.5 * (ltwh[3] - imageWidthHeight[1]))
9103
+ ltwh[0] += padLeftTop[0]
9104
+ ltwh[1] += padLeftTop[1]
9105
+ ltwh[2] = imageWidthHeight[0]
9106
+ ltwh[3] = imageWidthHeight[1]
9107
+ this.draw2DMain(ltwh, axCorSag, customMM)
9108
+ }
9109
+ if (customMM === Infinity || customMM === -Infinity || axCorSag === SLICE_TYPE.RENDER) {
9110
+ return
9111
+ }
9112
+ if (leftTopWidthHeight[2] !== 0 && leftTopWidthHeight[3] !== 0) {
9113
+ // issue1065
9114
+ this.drawSliceOrientationText(leftTopWidthHeight, axCorSag, padLeftTop)
9115
+ }
9116
+ }
9117
+
8379
9118
  // not included in public docs
8380
9119
  // determine 3D model view projection matrix
8381
9120
  calculateMvpMatrix(_unused: unknown, leftTopWidthHeight = [0, 0, 0, 0], azimuth: number, elevation: number): mat4[] {
@@ -9074,7 +9813,13 @@ export class Niivue {
9074
9813
  * @param style - label style
9075
9814
  * @param point - 3D point on the model
9076
9815
  */
9077
- addLabel(text: string, style: NVLabel3DStyle, points?: number[] | number[][]): NVLabel3D {
9816
+ addLabel(
9817
+ text: string,
9818
+ style: NVLabel3DStyle,
9819
+ points?: number[] | number[][],
9820
+ anchor?: LabelAnchorPoint,
9821
+ onClick?: (label: NVLabel3D) => void
9822
+ ): NVLabel3D {
9078
9823
  const defaultStyle = {
9079
9824
  textColor: this.opts.legendTextColor,
9080
9825
  textScale: 1.0,
@@ -9086,7 +9831,7 @@ export class Niivue {
9086
9831
  bulletColor: this.opts.legendTextColor
9087
9832
  }
9088
9833
  const labelStyle = style ? { ...defaultStyle, ...style } : { ...defaultStyle }
9089
- const label = new NVLabel3D(text, labelStyle, points)
9834
+ const label = new NVLabel3D(text, { ...labelStyle }, points, anchor, onClick)
9090
9835
  this.document.labels.push(label)
9091
9836
  return label
9092
9837
  }
@@ -9109,6 +9854,75 @@ export class Niivue {
9109
9854
  }
9110
9855
 
9111
9856
  getLabelAtPoint(screenPoint: [number, number]): NVLabel3D | null {
9857
+ const scale = 1.0
9858
+ const size = this.opts.textHeight * Math.min(this.gl.canvas.height, this.gl.canvas.width) * scale
9859
+ const verticalMargin = this.opts.textHeight * this.gl.canvas.height * scale
9860
+
9861
+ // get all non-connectome labels
9862
+ for (const label of this.document.labels) {
9863
+ if (label.anchor == null || label.anchor === LabelAnchorPoint.NONE) {
9864
+ continue
9865
+ }
9866
+
9867
+ const labelSize = this.opts.textHeight * this.gl.canvas.height * label.style.textScale
9868
+ const textHeight = this.textHeight(labelSize, label.text)
9869
+ const textWidth = this.textWidth(labelSize, label.text)
9870
+
9871
+ if (label.anchor & LabelAnchorFlag.LEFT) {
9872
+ if (screenPoint[0] > textWidth) {
9873
+ continue
9874
+ }
9875
+ }
9876
+
9877
+ if (label.anchor & LabelAnchorFlag.CENTER) {
9878
+ if (screenPoint[0] < (this.gl.canvas.width - textWidth) / 2) {
9879
+ continue
9880
+ }
9881
+
9882
+ if (screenPoint[0] > (this.gl.canvas.width + textWidth) / 2) {
9883
+ continue
9884
+ }
9885
+ }
9886
+
9887
+ if (label.anchor & LabelAnchorFlag.RIGHT) {
9888
+ if (screenPoint[0] < this.gl.canvas.width - textWidth) {
9889
+ continue
9890
+ }
9891
+ }
9892
+
9893
+ if (label.anchor & LabelAnchorFlag.TOP) {
9894
+ if (screenPoint[1] < verticalMargin / 2) {
9895
+ continue
9896
+ }
9897
+
9898
+ if (screenPoint[1] > textHeight + verticalMargin / 2) {
9899
+ continue
9900
+ }
9901
+ }
9902
+
9903
+ if (label.anchor & LabelAnchorFlag.MIDDLE) {
9904
+ if (screenPoint[1] < (this.gl.canvas.height - textHeight - verticalMargin) / 2) {
9905
+ continue
9906
+ }
9907
+
9908
+ if (screenPoint[1] > (this.gl.canvas.height + textHeight - verticalMargin / 2) / 2) {
9909
+ continue
9910
+ }
9911
+ }
9912
+
9913
+ if (label.anchor & LabelAnchorFlag.BOTTOM) {
9914
+ if (screenPoint[1] < this.gl.canvas.height - textHeight - verticalMargin) {
9915
+ continue
9916
+ }
9917
+
9918
+ if (screenPoint[1] > this.gl.canvas.height - verticalMargin / 2) {
9919
+ continue
9920
+ }
9921
+ }
9922
+
9923
+ // label passed all tests
9924
+ return label
9925
+ }
9112
9926
  log.debug('screenPoint', screenPoint)
9113
9927
  const panelHeight = this.getLegendPanelHeight()
9114
9928
  const panelWidth = this.getLegendPanelWidth()
@@ -9124,10 +9938,7 @@ export class Niivue {
9124
9938
  return null
9125
9939
  }
9126
9940
 
9127
- const scale = 1.0
9128
- const size = this.opts.textHeight * Math.min(this.gl.canvas.height, this.gl.canvas.width) * scale
9129
-
9130
- const labels = this.getAllLabels()
9941
+ const labels = this.getConnectomeLabels()
9131
9942
  for (const label of labels) {
9132
9943
  const labelSize = this.opts.textHeight * this.gl.canvas.height * label.style.textScale
9133
9944
  const textHeight = this.textHeight(labelSize, label.text)
@@ -9165,11 +9976,11 @@ export class Niivue {
9165
9976
  draw3DLabel(
9166
9977
  label: NVLabel3D,
9167
9978
  pos: vec2,
9168
- mvpMatrix: mat4,
9169
- leftTopWidthHeight: number[],
9170
- bulletMargin = 0,
9171
- legendWidth: number,
9172
- secondPass = false
9979
+ mvpMatrix?: mat4,
9980
+ leftTopWidthHeight?: number[],
9981
+ bulletMargin?: number,
9982
+ legendWidth?: number,
9983
+ secondPass?: boolean
9173
9984
  ): void {
9174
9985
  const text = label.text
9175
9986
  const left = pos[0]
@@ -9195,17 +10006,19 @@ export class Niivue {
9195
10006
 
9196
10007
  let textLeft = left
9197
10008
 
9198
- if (label.style.textAlignment !== LabelTextAlignment.LEFT) {
9199
- const textWidth = this.textWidth(label.style.textScale, label.text) * size
9200
- if (label.style.textAlignment === LabelTextAlignment.RIGHT) {
9201
- textLeft = left + legendWidth - size * 1.5 - textWidth
10009
+ if (legendWidth) {
10010
+ if (label.style.textAlignment !== LabelTextAlignment.LEFT) {
10011
+ const textWidth = this.textWidth(label.style.textScale, label.text) * size
10012
+ if (label.style.textAlignment === LabelTextAlignment.RIGHT) {
10013
+ textLeft = left + legendWidth - size * 1.5 - textWidth
10014
+ } else {
10015
+ const remaining = legendWidth - (bulletMargin || size)
10016
+ textLeft += (remaining - textWidth) / 2
10017
+ }
9202
10018
  } else {
9203
- const remaining = legendWidth - (bulletMargin || size)
9204
- textLeft += (remaining - textWidth) / 2
10019
+ // textLeft += size / 2;
10020
+ textLeft += bulletMargin
9205
10021
  }
9206
- } else {
9207
- // textLeft += size / 2;
9208
- textLeft += bulletMargin
9209
10022
  }
9210
10023
 
9211
10024
  this.drawText([textLeft, top], text, label.style.textScale, label.style.textColor)
@@ -9213,7 +10026,7 @@ export class Niivue {
9213
10026
 
9214
10027
  // not included in public docs
9215
10028
  draw3DLabels(mvpMatrix: mat4, leftTopWidthHeight: number[], secondPass = false): void {
9216
- const labels = this.getAllLabels()
10029
+ const labels = this.getConnectomeLabels()
9217
10030
  if (!this.opts.showLegend || labels.length === 0) {
9218
10031
  return
9219
10032
  }
@@ -9263,6 +10076,59 @@ export class Niivue {
9263
10076
  }
9264
10077
  }
9265
10078
 
10079
+ drawAnchoredLabels(): void {
10080
+ const size = this.opts.textHeight * Math.min(this.gl.canvas.height, this.gl.canvas.width) * 1.0
10081
+ const anchoredLabels = this.document.labels.filter((l) => l.anchor != null && l.anchor !== LabelAnchorPoint.NONE)
10082
+ for (const label of anchoredLabels) {
10083
+ const text = label.text
10084
+ const textHeight = this.textHeight(label.style.textScale, text) * size
10085
+ const textWidth = this.textWidth(label.style.textScale, text) * size
10086
+ let left: number
10087
+ let top: number
10088
+
10089
+ const scale = 1.0 // we may want to make this adjustable in the future
10090
+ const verticalMargin = this.opts.textHeight * this.gl.canvas.height * scale
10091
+ const rectHeightDiff = verticalMargin
10092
+ let rectWidthDiff = verticalMargin / 4
10093
+ let rectHorizontalOffset = 0
10094
+ let rectVerticalOffset = 0
10095
+
10096
+ if (label.anchor & LabelAnchorFlag.LEFT) {
10097
+ left = 0
10098
+ }
10099
+
10100
+ if (label.anchor & LabelAnchorFlag.RIGHT) {
10101
+ left = this.canvas.width - textWidth
10102
+ rectHorizontalOffset -= verticalMargin / 4
10103
+ }
10104
+
10105
+ if (label.anchor & LabelAnchorFlag.CENTER) {
10106
+ left = (this.canvas.width - textWidth) / 2
10107
+ rectHorizontalOffset -= verticalMargin / 4
10108
+ rectWidthDiff += verticalMargin / 4
10109
+ }
10110
+
10111
+ if (label.anchor & LabelAnchorFlag.TOP) {
10112
+ top = 0
10113
+ }
10114
+
10115
+ if (label.anchor & LabelAnchorFlag.MIDDLE) {
10116
+ top = (this.canvas.height - textHeight - verticalMargin) / 2
10117
+ rectVerticalOffset -= verticalMargin / 4
10118
+ }
10119
+
10120
+ if (label.anchor & LabelAnchorFlag.BOTTOM) {
10121
+ top = this.canvas.height - textHeight - verticalMargin
10122
+ rectVerticalOffset -= verticalMargin / 4
10123
+ }
10124
+ this.drawRect(
10125
+ [left + rectHorizontalOffset, top + rectVerticalOffset, textWidth + rectWidthDiff, textHeight + rectHeightDiff],
10126
+ label.style.backgroundColor
10127
+ )
10128
+ this.draw3DLabel(label, [left, top])
10129
+ }
10130
+ }
10131
+
9266
10132
  // not included in public docs
9267
10133
  draw3D(
9268
10134
  leftTopWidthHeight = [0, 0, 0, 0],
@@ -9355,6 +10221,7 @@ export class Niivue {
9355
10221
  this.readyForSync = true
9356
10222
  this.sync()
9357
10223
  this.draw3DLabels(mvpMatrix, relativeLTWH, true)
10224
+
9358
10225
  return posString
9359
10226
  }
9360
10227
 
@@ -10290,7 +11157,14 @@ export class Niivue {
10290
11157
  }
10291
11158
  }
10292
11159
  const isDrawPenDown = isFinite(this.drawPenLocation[0]) && this.opts.drawingEnabled
11160
+
10293
11161
  const { volScale } = this.sliceScale()
11162
+ const actualScale = volScale.slice()
11163
+ if (this.opts.multiplanarEqualSize) {
11164
+ volScale[0] = 1
11165
+ volScale[1] = 1
11166
+ volScale[2] = 1
11167
+ }
10294
11168
  if (typeof this.opts.multiplanarPadPixels !== 'number') {
10295
11169
  log.debug('multiplanarPadPixels must be numeric')
10296
11170
  }
@@ -10335,44 +11209,49 @@ export class Niivue {
10335
11209
  isDrawGrid = true
10336
11210
  }
10337
11211
  }
11212
+ let ltwh = ltwh2x2
10338
11213
  if (isDrawColumn) {
10339
- let ltwh = ltwh1x3
11214
+ ltwh = ltwh1x3
10340
11215
  if (isShowRender || (this.opts.multiplanarShowRender === SHOW_RENDER.AUTO && ltwh1x4[4] >= ltwh1x3[4])) {
10341
11216
  ltwh = ltwh1x4
10342
11217
  } else {
10343
11218
  isDraw3D = false
10344
11219
  }
10345
- const sX = volScale[0] * ltwh[4]
10346
- const sY = volScale[1] * ltwh[4]
10347
- const sZ = volScale[2] * ltwh[4]
10348
- const sMx = mx * ltwh[4]
11220
+ } else if (isDrawRow) {
11221
+ ltwh = ltwh3x1
11222
+ if (isShowRender || (this.opts.multiplanarShowRender === SHOW_RENDER.AUTO && ltwh4x1[4] >= ltwh3x1[4])) {
11223
+ ltwh = ltwh4x1
11224
+ } else {
11225
+ isDraw3D = false
11226
+ }
11227
+ }
11228
+ const sX = volScale[0] * ltwh[4]
11229
+ const sY = volScale[1] * ltwh[4]
11230
+ const sZ = volScale[2] * ltwh[4]
11231
+ const actualX = actualScale[0] * ltwh[4]
11232
+ const actualY = actualScale[1] * ltwh[4]
11233
+ const actualZ = actualScale[2] * ltwh[4]
11234
+ if (isDrawColumn) {
10349
11235
  // draw axial
10350
- this.draw2D([ltwh[0], ltwh[1], sX, sY], 0)
11236
+ this.draw2D([ltwh[0], ltwh[1], sX, sY], 0, NaN, [actualX, actualY])
10351
11237
  // draw coronal
10352
- this.draw2D([ltwh[0], ltwh[1] + sY + pad, sX, sZ], 1)
11238
+ this.draw2D([ltwh[0], ltwh[1] + sY + pad, sX, sZ], 1, NaN, [actualX, actualZ])
10353
11239
  // draw sagittal
10354
- this.draw2D([ltwh[0], ltwh[1] + sY + pad + sZ + pad, sY, sZ], 2)
11240
+ this.draw2D([ltwh[0], ltwh[1] + sY + pad + sZ + pad, sY, sZ], 2, NaN, [actualY, actualZ])
10355
11241
  if (isDraw3D) {
11242
+ const sMx = mx * ltwh[4]
10356
11243
  this.draw3D([ltwh[0], ltwh[1] + sY + sZ + sZ + pad * 3, sMx, sMx])
10357
11244
  }
10358
11245
  } else if (isDrawRow) {
10359
- let ltwh = ltwh3x1
10360
- if (isShowRender || (this.opts.multiplanarShowRender === SHOW_RENDER.AUTO && ltwh4x1[4] >= ltwh3x1[4])) {
10361
- ltwh = ltwh4x1
10362
- } else {
10363
- isDraw3D = false
10364
- }
10365
- const sX = volScale[0] * ltwh[4]
10366
- const sY = volScale[1] * ltwh[4]
10367
- const sZ = volScale[2] * ltwh[4]
10368
11246
  // draw axial
10369
- this.draw2D([ltwh[0], ltwh[1], sX, sY], 0)
11247
+ this.draw2D([ltwh[0], ltwh[1], sX, sY], 0, NaN, [actualX, actualY])
10370
11248
  // draw coronal
10371
- this.draw2D([ltwh[0] + sX + pad, ltwh[1], sX, sZ], 1)
11249
+ this.draw2D([ltwh[0] + sX + pad, ltwh[1], sX, sZ], 1, NaN, [actualX, actualZ])
10372
11250
  // draw sagittal
10373
- this.draw2D([ltwh[0] + sX + sX + pad * 2, ltwh[1], sY, sZ], 2)
11251
+ this.draw2D([ltwh[0] + sX + sX + pad * 2, ltwh[1], sY, sZ], 2, NaN, [actualY, actualZ])
10374
11252
  if (isDraw3D) {
10375
- this.draw3D([ltwh[0] + sX + sX + sY + pad * 3, ltwh[1], ltwh[3], ltwh[3]])
11253
+ const sMx = mx * ltwh[4]
11254
+ this.draw3D([ltwh[0] + sX + sX + sY + pad * 3, ltwh[1], sMx, sMx])
10376
11255
  }
10377
11256
  } else if (isDrawGrid) {
10378
11257
  // did the user turn off 3D render view in multiplanar?
@@ -10383,16 +11262,12 @@ export class Niivue {
10383
11262
  if (this.opts.multiplanarShowRender === SHOW_RENDER.AUTO) {
10384
11263
  isDraw3D = true
10385
11264
  }
10386
- const ltwh = ltwh2x2
10387
- const sX = volScale[0] * ltwh[4]
10388
- const sY = volScale[1] * ltwh[4]
10389
- const sZ = volScale[2] * ltwh[4]
10390
11265
  // draw axial
10391
- this.draw2D([ltwh[0], ltwh[1] + sZ + pad, sX, sY], 0)
11266
+ this.draw2D([ltwh[0], ltwh[1] + sZ + pad, sX, sY], 0, NaN, [actualX, actualY])
10392
11267
  // draw coronal
10393
- this.draw2D([ltwh[0], ltwh[1], sX, sZ], 1)
11268
+ this.draw2D([ltwh[0], ltwh[1], sX, sZ], 1, NaN, [actualX, actualZ])
10394
11269
  // draw sagittal
10395
- this.draw2D([ltwh[0] + sX + pad, ltwh[1], sY, sZ], 2)
11270
+ this.draw2D([ltwh[0] + sX + pad, ltwh[1], sY, sZ], 2, NaN, [actualY, actualZ])
10396
11271
  if (isDraw3D) {
10397
11272
  this.draw3D([ltwh[0] + sX + pad, ltwh[1] + sZ + pad, sY, sY])
10398
11273
  }
@@ -10457,12 +11332,44 @@ export class Niivue {
10457
11332
  width,
10458
11333
  height
10459
11334
  ])
11335
+ return
11336
+ }
11337
+
11338
+ // draw circle at mouse position if clickToSegment is enabled
11339
+ if (this.opts.clickToSegment) {
11340
+ const x = this.mousePos[0]
11341
+ const y = this.mousePos[1]
11342
+ // check if hovering over the 3D render tile
11343
+ if (this.inRenderTile(x, y) >= 0) {
11344
+ // exit early since we do not want to draw the cursor here!
11345
+ return
11346
+ }
11347
+ // determine the tile the mouse is hovering in
11348
+ const tileIdx = this.tileIndex(x, y)
11349
+ // if a valid tile index, draw the circle
11350
+ if (tileIdx > -1) {
11351
+ // get fov in mm for this plane presented in the tile
11352
+ const fovMM = this.screenSlices[tileIdx].fovMM
11353
+ // get the left, top, width, height of the tile in pixels
11354
+ const ltwh = this.screenSlices[tileIdx].leftTopWidthHeight
11355
+ // calculate the pixel to mm scale so we can draw the circle
11356
+ // in pixels (so it is highres) but with the radius specified in mm
11357
+ const pixPerMM = ltwh[2] / fovMM[0]
11358
+ // get the crosshair color, but replace the alpha because we want it to be transparent
11359
+ // no matter what. We want to see the image data underneath the circle.
11360
+ const color = this.opts.crosshairColor
11361
+ const segmentCursorColor = [color[0], color[1], color[2], 0.4]
11362
+ const radius = this.opts.clickToSegmentRadius * pixPerMM
11363
+ this.drawCircle([x - radius, y - radius, radius * 2, radius * 2], segmentCursorColor, 1)
11364
+ }
10460
11365
  }
11366
+
10461
11367
  const pos = this.frac2mm([this.scene.crosshairPos[0], this.scene.crosshairPos[1], this.scene.crosshairPos[2]])
10462
11368
 
10463
11369
  posString = pos[0].toFixed(2) + '×' + pos[1].toFixed(2) + '×' + pos[2].toFixed(2)
10464
11370
  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)
10465
11371
  this.sync()
11372
+ this.drawAnchoredLabels()
10466
11373
  return posString
10467
11374
  }
10468
11375