@niivue/niivue 0.44.1 → 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,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 = {
@@ -347,6 +358,7 @@ export class Niivue {
347
358
  orientCubeShader?: Shader
348
359
  orientCubeShaderVAO: WebGLVertexArrayObject | null = null
349
360
  rectShader?: Shader
361
+ rectOutlineShader?: Shader
350
362
  renderShader?: Shader
351
363
  lineShader?: Shader
352
364
  line3DShader?: Shader
@@ -392,7 +404,19 @@ export class Niivue {
392
404
  extentsMax?: vec3
393
405
  // ResizeObserver
394
406
  private resizeObserver: ResizeObserver | null = null
395
- 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
+
396
420
  readyForSync = false
397
421
 
398
422
  // UI Data
@@ -442,7 +466,7 @@ export class Niivue {
442
466
 
443
467
  cuboidVertexBuffer?: WebGLBuffer
444
468
 
445
- 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
446
470
  volumeObject3D: NiivueObject3D | null = null
447
471
  pivot3D = [0, 0, 0] // center for rendering rotation
448
472
  furthestFromPivot = 10.0 // most distant point from pivot
@@ -479,6 +503,10 @@ export class Niivue {
479
503
  Name: 'Hemispheric',
480
504
  Frag: fragMeshHemiShader
481
505
  },
506
+ {
507
+ Name: 'Crevice',
508
+ Frag: fragMeshShaderCrevice
509
+ },
482
510
  {
483
511
  Name: 'Edge',
484
512
  Frag: fragMeshEdgeShader
@@ -907,7 +935,11 @@ export class Niivue {
907
935
  * @deprecated use broadcastTo instead
908
936
  * @see {@link https://niivue.github.io/niivue/features/sync.mesh.html | live demo usage}
909
937
  */
910
- 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
+ }
911
943
  this.otherNV = otherNV
912
944
  this.syncOpts = { ...syncOpts }
913
945
  }
@@ -924,10 +956,71 @@ export class Niivue {
924
956
  * @see {@link https://niivue.github.io/niivue/features/sync.mesh.html | live demo usage}
925
957
  */
926
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
+ }
927
963
  this.otherNV = otherNV
928
964
  this.syncOpts = syncOpts
929
965
  }
930
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
+
931
1024
  /**
932
1025
  * Sync the scene controls (orientation, crosshair location, etc.) from one Niivue instance to another. useful for using one canvas to drive another.
933
1026
  * @internal
@@ -941,44 +1034,52 @@ export class Niivue {
941
1034
  if (!this.gl || !this.otherNV || typeof this.otherNV === 'undefined') {
942
1035
  return
943
1036
  }
944
- // if (!this.otherNV.readyForSync || !this.readyForSync) {
945
- // return;
946
- // }
947
1037
  // canvas must have focus to send messages issue706
948
1038
  if (!(this.gl.canvas as HTMLCanvasElement).matches(':focus')) {
949
1039
  return
950
1040
  }
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) {
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
954
1074
  if (this.syncOpts['2d']) {
955
- this.otherNV.scene.crosshairPos = this.otherNV.mm2frac(thisMM)
956
- this.otherNV.scene.pan2Dxyzmm = vec4.clone(this.scene.pan2Dxyzmm)
1075
+ this.doSync2d(this.otherNV[i])
957
1076
  }
1077
+ // legacy 3d option for multiple properties
958
1078
  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()
1079
+ this.doSync3d(this.otherNV[i])
981
1080
  }
1081
+ this.otherNV[i].drawScene()
1082
+ this.otherNV[i].createOnLocationChange()
982
1083
  }
983
1084
  }
984
1085
 
@@ -1099,6 +1200,11 @@ export class Niivue {
1099
1200
  const [x, y] = [pos.x * this.uiData.dpr!, pos.y * this.uiData.dpr!]
1100
1201
  const label = this.getLabelAtPoint([x, y])
1101
1202
  if (label) {
1203
+ // check for user defined onclick handler
1204
+ if (label.onClick) {
1205
+ label.onClick(label)
1206
+ return
1207
+ }
1102
1208
  // find associated mesh
1103
1209
  for (const mesh of this.meshes) {
1104
1210
  if (mesh.type !== MeshType.CONNECTOME) {
@@ -1275,7 +1381,6 @@ export class Niivue {
1275
1381
  const mmLength = vec3.len(v)
1276
1382
  const voxStart = this.frac2vox(fracStart)
1277
1383
  const voxEnd = this.frac2vox(fracEnd)
1278
-
1279
1384
  this.onDragRelease({
1280
1385
  fracStart,
1281
1386
  fracEnd,
@@ -1328,6 +1433,11 @@ export class Niivue {
1328
1433
  const fracStart = this.canvasPos2frac([this.uiData.dragStart[0], this.uiData.dragStart[1]])
1329
1434
  const fracEnd = this.canvasPos2frac([this.uiData.dragEnd[0], this.uiData.dragEnd[1]])
1330
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
+ }
1331
1441
  if (this.opts.dragMode !== DRAG_MODE.contrast) {
1332
1442
  return
1333
1443
  }
@@ -1447,6 +1557,16 @@ export class Niivue {
1447
1557
  this.drawScene()
1448
1558
  this.uiData.prevX = this.uiData.currX
1449
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()
1450
1570
  }
1451
1571
  }
1452
1572
 
@@ -2051,7 +2171,7 @@ export class Niivue {
2051
2171
  this.addVolume(volume)
2052
2172
  // set drawing enabled to make sure
2053
2173
  // the new drawing bitmap matches the background volume dims
2054
- this.setDrawingEnabled(true)
2174
+ // this.setDrawingEnabled(true)
2055
2175
  }
2056
2176
  })
2057
2177
  .catch((e) => {
@@ -4193,6 +4313,29 @@ export class Niivue {
4193
4313
  y = Math.min(Math.max(y, 0), dy - 1)
4194
4314
  z = Math.min(Math.max(z, 0), dz - 1)
4195
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
+ }
4196
4339
  }
4197
4340
 
4198
4341
  // not included in public docs
@@ -4279,6 +4422,140 @@ export class Niivue {
4279
4422
  }
4280
4423
  }
4281
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
+
4282
4559
  // a voxel can be defined as having 6, 18 or 26 neighbors:
4283
4560
  // 6: neighbors share faces (distance=1)
4284
4561
  // 18: neighbors share faces (distance=1) or edges (1.4)
@@ -4381,7 +4658,9 @@ export class Niivue {
4381
4658
  growSelectedCluster = 0, // if non-zero, growth based on background intensity POSITIVE_INFINITY for selected or bright, NEGATIVE_INFINITY for selected or darker
4382
4659
  forceMin = NaN,
4383
4660
  forceMax = NaN,
4384
- neighbors = 6
4661
+ neighbors = 6,
4662
+ // option for only flood filling within max distance from seed voxel
4663
+ maxDistanceMM = Number.POSITIVE_INFINITY
4385
4664
  ): void {
4386
4665
  if (!this.drawBitmap) {
4387
4666
  throw new Error('drawBitmap undefined')
@@ -4407,10 +4686,29 @@ export class Niivue {
4407
4686
  if (img.length !== nxy * dims[2]) {
4408
4687
  return
4409
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
+ }
4410
4696
  function xyz2vx(pt: number[]): number {
4411
4697
  // provided an XYZ 3D point, provide address in 1D array
4412
4698
  return pt[0] + pt[1] * nx + pt[2] * nxy
4413
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
+ }
4414
4712
  const seedVx = xyz2vx(seedXYZ)
4415
4713
  const seedColor = img[seedVx]
4416
4714
  if (seedColor === newColor) {
@@ -4424,6 +4722,12 @@ export class Niivue {
4424
4722
  for (let i = 1; i < nxyz; i++) {
4425
4723
  img[i] = 0
4426
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
+ }
4427
4731
  img[i] = 1
4428
4732
  }
4429
4733
  }
@@ -4455,6 +4759,12 @@ export class Niivue {
4455
4759
  for (let i = 1; i < nxyz; i++) {
4456
4760
  img[i] = 0
4457
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
+ }
4458
4768
  img[i] = 1
4459
4769
  }
4460
4770
  }
@@ -5163,6 +5473,8 @@ export class Niivue {
5163
5473
  // rect shader (crosshair): horizontal and vertical lines only
5164
5474
  this.rectShader = new Shader(gl, vertRectShader, fragRectShader)
5165
5475
  this.rectShader.use(gl)
5476
+ this.rectOutlineShader = new Shader(gl, vertRectShader, fragRectOutlineShader)
5477
+ this.rectOutlineShader.use(gl)
5166
5478
  // line shader: diagonal lines
5167
5479
  this.lineShader = new Shader(gl, vertLineShader, fragRectShader)
5168
5480
  this.lineShader.use(gl)
@@ -5338,14 +5650,46 @@ export class Niivue {
5338
5650
 
5339
5651
  /**
5340
5652
  * 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);
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
+ * });
5345
5670
  * @see {@link https://niivue.github.io/niivue/features/draw2.html | live demo usage}
5346
5671
  */
5347
- 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
5348
5691
  const hdr = this.volumes[layer].hdr!
5692
+ const pixDimsRAS = this.volumes[layer].pixDimsRAS!
5349
5693
  let slope = hdr.scl_slope
5350
5694
  if (isNaN(slope)) {
5351
5695
  slope = 1
@@ -5384,6 +5728,48 @@ export class Niivue {
5384
5728
  mask[i] = 0
5385
5729
  }
5386
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)
5387
5773
  }
5388
5774
  // Welfords method
5389
5775
  // https://www.embeddedrelated.com/showarticle/785.php
@@ -5444,7 +5830,8 @@ export class Niivue {
5444
5830
  cal_min: this.volumes[layer].cal_min!,
5445
5831
  cal_max: this.volumes[layer].cal_max!,
5446
5832
  robust_min: this.volumes[layer].robust_min!,
5447
- robust_max: this.volumes[layer].robust_max!
5833
+ robust_max: this.volumes[layer].robust_max!,
5834
+ area
5448
5835
  }
5449
5836
  }
5450
5837
 
@@ -6796,6 +7183,7 @@ export class Niivue {
6796
7183
  * @see {@link https://niivue.github.io/niivue/features/colormaps.html | live demo usage}
6797
7184
  */
6798
7185
  setGamma(gamma = 1.0): void {
7186
+ this.scene.gamma = gamma
6799
7187
  cmapper.gamma = gamma
6800
7188
  this.updateGLVolume()
6801
7189
  }
@@ -7207,34 +7595,72 @@ export class Niivue {
7207
7595
  this.scene.crosshairPos = vec3.clone(texFrac)
7208
7596
  }
7209
7597
  if (this.opts.drawingEnabled) {
7598
+ // drawing done in voxels
7210
7599
  const pt = this.frac2vox(this.scene.crosshairPos) as [number, number, number]
7211
7600
  // if click-to-segment enabled
7212
7601
  if (this.opts.clickToSegment) {
7213
- const radius = this.opts.clickToSegmentRadius
7214
- 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
7215
7625
  const brightOrDark = this.opts.clickToSegmentBright ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY
7216
- this.drawPenFillPts = []
7217
7626
  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
- }
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)
7233
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
+ )
7234
7656
  this.drawScene()
7235
7657
  this.createOnLocationChange(axCorSag)
7236
7658
  // get the volume of the segmented region
7237
- const info = this.getDescriptives(0, [], true)
7659
+ const info = this.getDescriptives({
7660
+ layer: 0,
7661
+ masks: [],
7662
+ drawingIsMask: true
7663
+ })
7238
7664
  this.onClickToSegment({ mL: info.volumeML, mm3: info.volumeMM3 })
7239
7665
  return
7240
7666
  }
@@ -7486,20 +7912,43 @@ export class Niivue {
7486
7912
  if (!this.rectShader) {
7487
7913
  throw new Error('rectShader undefined')
7488
7914
  }
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
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
+ }
7503
7952
  }
7504
7953
 
7505
7954
  drawCircle(leftTopWidthHeight: number[], circleColor = this.opts.fontColor, fillPercent = 1.0): void {
@@ -7551,9 +8000,26 @@ export class Niivue {
7551
8000
  return labels
7552
8001
  }
7553
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
+
7554
8020
  getBulletMarginWidth(): number {
7555
8021
  let bulletMargin = 0
7556
- const labels = this.getAllLabels()
8022
+ const labels = this.getConnectomeLabels()
7557
8023
  if (labels.length === 0) {
7558
8024
  return 0
7559
8025
  }
@@ -7578,7 +8044,7 @@ export class Niivue {
7578
8044
  }
7579
8045
 
7580
8046
  getLegendPanelWidth(): number {
7581
- const labels = this.getAllLabels()
8047
+ const labels = this.getConnectomeLabels()
7582
8048
  if (!this.opts.showLegend || labels.length === 0) {
7583
8049
  return 0
7584
8050
  }
@@ -7609,7 +8075,7 @@ export class Niivue {
7609
8075
  }
7610
8076
 
7611
8077
  getLegendPanelHeight(): number {
7612
- const labels = this.getAllLabels()
8078
+ const labels = this.getConnectomeLabels()
7613
8079
  let height = 0
7614
8080
  const scale = 1.0 // we may want to make this adjustable in the future
7615
8081
  const verticalMargin = this.opts.textHeight * this.gl.canvas.height * scale
@@ -8124,7 +8590,11 @@ export class Niivue {
8124
8590
 
8125
8591
  // not included in public docs
8126
8592
  // show text labels for L/R, A/P, I/S dimensions
8127
- drawSliceOrientationText(leftTopWidthHeight: number[], axCorSag: SLICE_TYPE): void {
8593
+ drawSliceOrientationText(
8594
+ leftTopWidthHeight: number[],
8595
+ axCorSag: SLICE_TYPE,
8596
+ padLeftTop: number[] = [NaN, NaN]
8597
+ ): void {
8128
8598
  this.gl.viewport(0, 0, this.gl.canvas.width, this.gl.canvas.height)
8129
8599
  let topText = 'S'
8130
8600
  if (axCorSag === SLICE_TYPE.AXIAL) {
@@ -8138,9 +8608,32 @@ export class Niivue {
8138
8608
  this.drawTextRightBelow([leftTopWidthHeight[0], leftTopWidthHeight[1]], leftText + topText)
8139
8609
  return
8140
8610
  }
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)
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
+ }
8144
8637
  }
8145
8638
 
8146
8639
  // not included in public docs
@@ -8183,7 +8676,7 @@ export class Niivue {
8183
8676
 
8184
8677
  // not included in public docs
8185
8678
  // draw 2D tile
8186
- draw2D(leftTopWidthHeight: number[], axCorSag: SLICE_TYPE, customMM = NaN): void {
8679
+ draw2DMain(leftTopWidthHeight: number[], axCorSag: SLICE_TYPE, customMM = NaN): void {
8187
8680
  let frac2mmTexture = this.volumes[0].frac2mm!.slice()
8188
8681
  let screen = this.screenFieldOfViewExtendedMM(axCorSag)
8189
8682
  let mesh2ortho = mat4.create()
@@ -8212,8 +8705,10 @@ export class Niivue {
8212
8705
  elevation = isRadiolgical ? -90 : 90
8213
8706
  }
8214
8707
  const gl = this.gl
8708
+ let isStretchToScreen = false
8215
8709
  if (leftTopWidthHeight[2] === 0 || leftTopWidthHeight[3] === 0) {
8216
8710
  // only one tile: stretch tile to fill whole screen.
8711
+ isStretchToScreen = true
8217
8712
  const pixPerMMw = gl.canvas.width / screen.fovMM[0]
8218
8713
  const pixPerMMh = gl.canvas.height / screen.fovMM[1]
8219
8714
  const pixPerMMmin = Math.min(pixPerMMw, pixPerMMh)
@@ -8372,10 +8867,42 @@ export class Niivue {
8372
8867
  // no crossbars for mosaic view
8373
8868
  this.drawCrosshairs3D(false, 0.15, obj.modelViewProjectionMatrix, true, this.opts.isSliceMM)
8374
8869
  }
8375
- this.drawSliceOrientationText(leftTopWidthHeight, axCorSag)
8870
+ if (isStretchToScreen) {
8871
+ // issue1065
8872
+ this.drawSliceOrientationText(leftTopWidthHeight, axCorSag)
8873
+ }
8376
8874
  this.readyForSync = true
8377
8875
  }
8378
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
+
8379
8906
  // not included in public docs
8380
8907
  // determine 3D model view projection matrix
8381
8908
  calculateMvpMatrix(_unused: unknown, leftTopWidthHeight = [0, 0, 0, 0], azimuth: number, elevation: number): mat4[] {
@@ -9074,7 +9601,13 @@ export class Niivue {
9074
9601
  * @param style - label style
9075
9602
  * @param point - 3D point on the model
9076
9603
  */
9077
- 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 {
9078
9611
  const defaultStyle = {
9079
9612
  textColor: this.opts.legendTextColor,
9080
9613
  textScale: 1.0,
@@ -9086,7 +9619,7 @@ export class Niivue {
9086
9619
  bulletColor: this.opts.legendTextColor
9087
9620
  }
9088
9621
  const labelStyle = style ? { ...defaultStyle, ...style } : { ...defaultStyle }
9089
- const label = new NVLabel3D(text, labelStyle, points)
9622
+ const label = new NVLabel3D(text, { ...labelStyle }, points, anchor, onClick)
9090
9623
  this.document.labels.push(label)
9091
9624
  return label
9092
9625
  }
@@ -9109,6 +9642,75 @@ export class Niivue {
9109
9642
  }
9110
9643
 
9111
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
+ }
9112
9714
  log.debug('screenPoint', screenPoint)
9113
9715
  const panelHeight = this.getLegendPanelHeight()
9114
9716
  const panelWidth = this.getLegendPanelWidth()
@@ -9124,10 +9726,7 @@ export class Niivue {
9124
9726
  return null
9125
9727
  }
9126
9728
 
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()
9729
+ const labels = this.getConnectomeLabels()
9131
9730
  for (const label of labels) {
9132
9731
  const labelSize = this.opts.textHeight * this.gl.canvas.height * label.style.textScale
9133
9732
  const textHeight = this.textHeight(labelSize, label.text)
@@ -9165,11 +9764,11 @@ export class Niivue {
9165
9764
  draw3DLabel(
9166
9765
  label: NVLabel3D,
9167
9766
  pos: vec2,
9168
- mvpMatrix: mat4,
9169
- leftTopWidthHeight: number[],
9170
- bulletMargin = 0,
9171
- legendWidth: number,
9172
- secondPass = false
9767
+ mvpMatrix?: mat4,
9768
+ leftTopWidthHeight?: number[],
9769
+ bulletMargin?: number,
9770
+ legendWidth?: number,
9771
+ secondPass?: boolean
9173
9772
  ): void {
9174
9773
  const text = label.text
9175
9774
  const left = pos[0]
@@ -9195,17 +9794,19 @@ export class Niivue {
9195
9794
 
9196
9795
  let textLeft = left
9197
9796
 
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
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
+ }
9202
9806
  } else {
9203
- const remaining = legendWidth - (bulletMargin || size)
9204
- textLeft += (remaining - textWidth) / 2
9807
+ // textLeft += size / 2;
9808
+ textLeft += bulletMargin
9205
9809
  }
9206
- } else {
9207
- // textLeft += size / 2;
9208
- textLeft += bulletMargin
9209
9810
  }
9210
9811
 
9211
9812
  this.drawText([textLeft, top], text, label.style.textScale, label.style.textColor)
@@ -9213,7 +9814,7 @@ export class Niivue {
9213
9814
 
9214
9815
  // not included in public docs
9215
9816
  draw3DLabels(mvpMatrix: mat4, leftTopWidthHeight: number[], secondPass = false): void {
9216
- const labels = this.getAllLabels()
9817
+ const labels = this.getConnectomeLabels()
9217
9818
  if (!this.opts.showLegend || labels.length === 0) {
9218
9819
  return
9219
9820
  }
@@ -9263,6 +9864,59 @@ export class Niivue {
9263
9864
  }
9264
9865
  }
9265
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
+
9266
9920
  // not included in public docs
9267
9921
  draw3D(
9268
9922
  leftTopWidthHeight = [0, 0, 0, 0],
@@ -9355,6 +10009,7 @@ export class Niivue {
9355
10009
  this.readyForSync = true
9356
10010
  this.sync()
9357
10011
  this.draw3DLabels(mvpMatrix, relativeLTWH, true)
10012
+
9358
10013
  return posString
9359
10014
  }
9360
10015
 
@@ -10290,7 +10945,14 @@ export class Niivue {
10290
10945
  }
10291
10946
  }
10292
10947
  const isDrawPenDown = isFinite(this.drawPenLocation[0]) && this.opts.drawingEnabled
10948
+
10293
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
+ }
10294
10956
  if (typeof this.opts.multiplanarPadPixels !== 'number') {
10295
10957
  log.debug('multiplanarPadPixels must be numeric')
10296
10958
  }
@@ -10335,44 +10997,49 @@ export class Niivue {
10335
10997
  isDrawGrid = true
10336
10998
  }
10337
10999
  }
11000
+ let ltwh = ltwh2x2
10338
11001
  if (isDrawColumn) {
10339
- let ltwh = ltwh1x3
11002
+ ltwh = ltwh1x3
10340
11003
  if (isShowRender || (this.opts.multiplanarShowRender === SHOW_RENDER.AUTO && ltwh1x4[4] >= ltwh1x3[4])) {
10341
11004
  ltwh = ltwh1x4
10342
11005
  } else {
10343
11006
  isDraw3D = false
10344
11007
  }
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]
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) {
10349
11023
  // draw axial
10350
- this.draw2D([ltwh[0], ltwh[1], sX, sY], 0)
11024
+ this.draw2D([ltwh[0], ltwh[1], sX, sY], 0, NaN, [actualX, actualY])
10351
11025
  // draw coronal
10352
- 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])
10353
11027
  // draw sagittal
10354
- 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])
10355
11029
  if (isDraw3D) {
11030
+ const sMx = mx * ltwh[4]
10356
11031
  this.draw3D([ltwh[0], ltwh[1] + sY + sZ + sZ + pad * 3, sMx, sMx])
10357
11032
  }
10358
11033
  } 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
11034
  // draw axial
10369
- this.draw2D([ltwh[0], ltwh[1], sX, sY], 0)
11035
+ this.draw2D([ltwh[0], ltwh[1], sX, sY], 0, NaN, [actualX, actualY])
10370
11036
  // draw coronal
10371
- 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])
10372
11038
  // draw sagittal
10373
- 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])
10374
11040
  if (isDraw3D) {
10375
- 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])
10376
11043
  }
10377
11044
  } else if (isDrawGrid) {
10378
11045
  // did the user turn off 3D render view in multiplanar?
@@ -10383,16 +11050,12 @@ export class Niivue {
10383
11050
  if (this.opts.multiplanarShowRender === SHOW_RENDER.AUTO) {
10384
11051
  isDraw3D = true
10385
11052
  }
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
11053
  // draw axial
10391
- 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])
10392
11055
  // draw coronal
10393
- this.draw2D([ltwh[0], ltwh[1], sX, sZ], 1)
11056
+ this.draw2D([ltwh[0], ltwh[1], sX, sZ], 1, NaN, [actualX, actualZ])
10394
11057
  // draw sagittal
10395
- 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])
10396
11059
  if (isDraw3D) {
10397
11060
  this.draw3D([ltwh[0] + sX + pad, ltwh[1] + sZ + pad, sY, sY])
10398
11061
  }
@@ -10457,12 +11120,44 @@ export class Niivue {
10457
11120
  width,
10458
11121
  height
10459
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
+ }
10460
11153
  }
11154
+
10461
11155
  const pos = this.frac2mm([this.scene.crosshairPos[0], this.scene.crosshairPos[1], this.scene.crosshairPos[2]])
10462
11156
 
10463
11157
  posString = pos[0].toFixed(2) + '×' + pos[1].toFixed(2) + '×' + pos[2].toFixed(2)
10464
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)
10465
11159
  this.sync()
11160
+ this.drawAnchoredLabels()
10466
11161
  return posString
10467
11162
  }
10468
11163