cloudmr-ux 4.6.1 → 4.6.3

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.
@@ -832,6 +832,12 @@ export default function CloudMrNiivueViewer(props) {
832
832
  (_a = nv.cloudMrCancelPolyline) === null || _a === void 0 ? void 0 : _a.call(nv);
833
833
  }
834
834
  }
835
+ function syncActiveShapeDraftRef() {
836
+ nv._cloudMrActiveShapeDraft = shapeDraftRef.current;
837
+ }
838
+ function clearActiveShapeDraftRef() {
839
+ nv._cloudMrActiveShapeDraft = null;
840
+ }
835
841
  function cancelPenDraftHandler() {
836
842
  var _a;
837
843
  var draft = penDraftRef.current;
@@ -920,6 +926,7 @@ export default function CloudMrNiivueViewer(props) {
920
926
  setShapeDraft(null);
921
927
  shapeDraftRef.current = null;
922
928
  nv._cloudMrShapeDraftActive = false;
929
+ clearActiveShapeDraftRef();
923
930
  if (drawShapeToolRef.current) {
924
931
  nvSetDrawingEnabled(true);
925
932
  }
@@ -932,6 +939,7 @@ export default function CloudMrNiivueViewer(props) {
932
939
  setShapeDraft(null);
933
940
  shapeDraftRef.current = null;
934
941
  nv._cloudMrShapeDraftActive = false;
942
+ clearActiveShapeDraftRef();
935
943
  if (drawShapeToolRef.current) {
936
944
  nvSetDrawingEnabled(true);
937
945
  }
@@ -940,11 +948,13 @@ export default function CloudMrNiivueViewer(props) {
940
948
  function onShapeDraftChange(draft) {
941
949
  setShapeDraft(draft);
942
950
  shapeDraftRef.current = draft;
951
+ syncActiveShapeDraftRef();
943
952
  }
944
953
  nv.onShapeDraftReady = function (draft) {
945
954
  setShapeDraft(draft);
946
955
  shapeDraftRef.current = draft;
947
956
  nv._cloudMrShapeDraftActive = true;
957
+ syncActiveShapeDraftRef();
948
958
  nvSetDrawingEnabled(false);
949
959
  };
950
960
  nv.onApplyActiveDraft = function () {
@@ -6,6 +6,7 @@ import {
6
6
  captureDeferredShapeDraft,
7
7
  captureShapeDraftFromClick,
8
8
  isDraftTooSmall,
9
+ isVoxelPartOfDraft,
9
10
  redrawDraftShape,
10
11
  shouldDeferShapeCommit,
11
12
  } from "./shapeDraftUtils.js";
@@ -1472,27 +1473,28 @@ function cloudMrHasApplyableDraft(nv) {
1472
1473
  return !!(nv._cloudMrShapeDraftActive || nv._cloudMrPenDraftActive);
1473
1474
  }
1474
1475
 
1475
- /**
1476
- * When no draft is active and the user clicks an existing ROI voxel while the
1477
- * rectangle or ellipse tool is selected, reconstruct a ShapeDraft from the
1478
- * clicked cluster so the bounding-box overlay reappears for re-editing.
1479
- */
1480
- function cloudMrTryReopenShapeDraftOnClick(nv) {
1476
+ function isClickOnActiveShapeDraft(nv) {
1477
+ const draft = nv._cloudMrActiveShapeDraft;
1478
+ if (!draft || !nv._cloudMrShapeDraftActive) return false;
1479
+ const vox = voxFromMouse(nv);
1480
+ if (!vox) return false;
1481
+ return isVoxelPartOfDraft(nv, draft, vox);
1482
+ }
1483
+
1484
+ function canReopenShapeDraftOnClick(nv) {
1481
1485
  const penType = nv.opts.penType;
1482
- if (
1483
- !nv.opts.deferShapeCommit ||
1484
- !nv.opts.drawingEnabled ||
1485
- nv._cloudMrShapeDraftActive ||
1486
- nv._cloudMrPenDraftActive ||
1487
- !isClickWithoutDrag(nv.uiData) ||
1488
- (penType !== NI_PEN_TYPE.RECTANGLE && penType !== NI_PEN_TYPE.ELLIPSE)
1489
- ) {
1490
- return false;
1491
- }
1486
+ return (
1487
+ nv.opts.deferShapeCommit &&
1488
+ nv.opts.drawingEnabled &&
1489
+ !nv._cloudMrShapeDraftActive &&
1490
+ !nv._cloudMrPenDraftActive &&
1491
+ (penType === NI_PEN_TYPE.RECTANGLE || penType === NI_PEN_TYPE.ELLIPSE)
1492
+ );
1493
+ }
1492
1494
 
1495
+ function cloudMrOpenShapeDraftFromClick(nv) {
1493
1496
  const reopenDraft = captureShapeDraftFromClick(nv);
1494
1497
  if (!reopenDraft) return false;
1495
-
1496
1498
  redrawDraftShape(nv, reopenDraft);
1497
1499
  nv._cloudMrSuppressDrawingChangedMouseUp = true;
1498
1500
  if (typeof nv.onShapeDraftReady === "function") {
@@ -1501,8 +1503,53 @@ function cloudMrTryReopenShapeDraftOnClick(nv) {
1501
1503
  return true;
1502
1504
  }
1503
1505
 
1506
+ /**
1507
+ * Re-enter rectangle/ellipse edit mode when clicking an existing shape ROI.
1508
+ */
1509
+ function cloudMrTryReopenShapeDraftOnClick(nv) {
1510
+ if (!isClickWithoutDrag(nv.uiData) || !canReopenShapeDraftOnClick(nv)) {
1511
+ return false;
1512
+ }
1513
+ return cloudMrOpenShapeDraftFromClick(nv);
1514
+ }
1515
+
1516
+ /**
1517
+ * While editing one shape, mousedown on another shape applies the current
1518
+ * draft immediately and opens the clicked shape for editing.
1519
+ */
1520
+ function cloudMrTrySwitchShapeDraftOnMouseDown(nv) {
1521
+ if (!nv._cloudMrShapeDraftActive || nv._cloudMrPenDraftActive) {
1522
+ return false;
1523
+ }
1524
+ if (!nv.opts.deferShapeCommit || !nv.opts.drawingEnabled) {
1525
+ return false;
1526
+ }
1527
+
1528
+ const vox = voxFromMouse(nv);
1529
+ if (!vox || isClickOnActiveShapeDraft(nv)) {
1530
+ return false;
1531
+ }
1532
+
1533
+ const dims = nv.back?.dims;
1534
+ if (!dims || !nv.drawBitmap) return false;
1535
+ const dx = dims[1];
1536
+ const dy = dims[2];
1537
+ const idx = vox[0] + vox[1] * dx + vox[2] * dx * dy;
1538
+ if (!nv.drawBitmap[idx]) {
1539
+ return false;
1540
+ }
1541
+
1542
+ if (typeof nv.onApplyActiveDraft === "function") {
1543
+ nv.onApplyActiveDraft();
1544
+ }
1545
+ return cloudMrOpenShapeDraftFromClick(nv);
1546
+ }
1547
+
1504
1548
  const _mouseDownListener = Niivue.prototype.mouseDownListener;
1505
1549
  Niivue.prototype.mouseDownListener = function cloudMrMouseDownListener(e) {
1550
+ if (e.button === 0 && cloudMrTrySwitchShapeDraftOnMouseDown(this)) {
1551
+ return;
1552
+ }
1506
1553
  if (shouldDeferFreehandCommit(this) && this.drawBitmap) {
1507
1554
  this._cloudMrFreehandSessionStartBitmap = this.drawBitmap.slice();
1508
1555
  this._cloudMrFreehandAxCorSag = -1;
@@ -1571,7 +1618,6 @@ Niivue.prototype.mouseUpListener = function cloudMrMouseUpListener() {
1571
1618
  }
1572
1619
 
1573
1620
  if (!pendingDraft?.baseBitmap) {
1574
- // No new draft drawn — check if user clicked an existing ROI to re-edit it
1575
1621
  cloudMrTryReopenShapeDraftOnClick(this);
1576
1622
  return;
1577
1623
  }
@@ -1579,7 +1625,6 @@ Niivue.prototype.mouseUpListener = function cloudMrMouseUpListener() {
1579
1625
  this.drawBitmap.set(pendingDraft.baseBitmap);
1580
1626
  this.refreshDrawing(true, false);
1581
1627
  this.drawScene();
1582
- // Tiny drag treated as a click — also try to reopen an existing ROI
1583
1628
  cloudMrTryReopenShapeDraftOnClick(this);
1584
1629
  return;
1585
1630
  }
@@ -49,6 +49,21 @@ export function captureDeferredShapeDraft(nv: any): {
49
49
  baseBitmap: Uint8Array | null;
50
50
  };
51
51
  export function shouldDeferShapeCommit(nv: any): any;
52
+ /**
53
+ * Flood-fill a connected voxel cluster from a seed.
54
+ * @returns {{ label: number, visited: Set<number>, voxels: [number,number,number][], bounds: object } | null}
55
+ */
56
+ export function floodFillClusterFromVox(nv: any, seedVox: any): {
57
+ label: number;
58
+ visited: Set<number>;
59
+ voxels: [number, number, number][];
60
+ bounds: object;
61
+ } | null;
62
+ /** True when a voxel belongs to the live draft overlay (drawn on top of baseBitmap). */
63
+ export function isVoxelPartOfDraft(nv: any, draft: any, seedVox: any): boolean;
64
+ export function eraseClusterFromBitmap(bitmap: any, visited: any): Uint8Array;
65
+ /** Guess rectangle vs ellipse from the filled voxel pattern, not the active tool. */
66
+ export function inferShapePenTypeFromCluster(cluster: any, axCorSag: any): 1 | 2;
52
67
  /**
53
68
  * When the user clicks on an existing filled ROI while the rectangle/ellipse tool
54
69
  * is active (but no draft is currently open), flood-fill the clicked cluster to
@@ -57,11 +72,11 @@ export function shouldDeferShapeCommit(nv: any): any;
57
72
  * Returns null if the click didn't land on a labeled voxel.
58
73
  */
59
74
  export function captureShapeDraftFromClick(nv: any): {
60
- ptA: number[];
61
- ptB: number[];
62
- penValue: any;
75
+ ptA: any[];
76
+ ptB: any[];
77
+ penValue: number;
63
78
  axCorSag: number;
64
- penType: any;
79
+ penType: 1 | 2;
65
80
  baseBitmap: Uint8Array;
66
81
  } | null;
67
82
  export type ShapeDraftKind = 'rectangle' | 'ellipse';
@@ -203,27 +203,21 @@ function inferAxCorSagFromBounds(x1, y1, z1, x2, y2, z2, fallback) {
203
203
  var spanY = y2 - y1;
204
204
  var spanZ = z2 - z1;
205
205
  if (spanZ <= spanX && spanZ <= spanY)
206
- return 0; // axial — flat in Z
206
+ return 0;
207
207
  if (spanY <= spanX && spanY <= spanZ)
208
- return 1; // coronal — flat in Y
208
+ return 1;
209
209
  if (spanX <= spanY && spanX <= spanZ)
210
- return 2; // sagittal — flat in X
210
+ return 2;
211
211
  return fallback;
212
212
  }
213
213
  /**
214
- * When the user clicks on an existing filled ROI while the rectangle/ellipse tool
215
- * is active (but no draft is currently open), flood-fill the clicked cluster to
216
- * reconstruct a ShapeDraft so the bounding-box overlay reappears for re-editing.
217
- *
218
- * Returns null if the click didn't land on a labeled voxel.
214
+ * Flood-fill a connected voxel cluster from a seed.
215
+ * @returns {{ label: number, visited: Set<number>, voxels: [number,number,number][], bounds: object } | null}
219
216
  */
220
- export function captureShapeDraftFromClick(nv) {
217
+ export function floodFillClusterFromVox(nv, seedVox) {
221
218
  var _a;
222
219
  var dims = (_a = nv.back) === null || _a === void 0 ? void 0 : _a.dims;
223
- if (!dims || !nv.drawBitmap)
224
- return null;
225
- var seedVox = voxFromMouse(nv);
226
- if (!seedVox)
220
+ if (!dims || !nv.drawBitmap || !seedVox)
227
221
  return null;
228
222
  var dx = dims[1];
229
223
  var dy = dims[2];
@@ -232,15 +226,20 @@ export function captureShapeDraftFromClick(nv) {
232
226
  var label = nv.drawBitmap[seedIdx];
233
227
  if (!label)
234
228
  return null;
235
- // BFS flood-fill to find connected cluster of the same label
236
229
  var visited = new Set();
237
230
  var queue = [seedIdx];
238
231
  visited.add(seedIdx);
239
- var x1 = Infinity, y1 = Infinity, z1 = Infinity;
240
- var x2 = -Infinity, y2 = -Infinity, z2 = -Infinity;
232
+ var voxels = [];
233
+ var x1 = Infinity;
234
+ var y1 = Infinity;
235
+ var z1 = Infinity;
236
+ var x2 = -Infinity;
237
+ var y2 = -Infinity;
238
+ var z2 = -Infinity;
241
239
  while (queue.length > 0) {
242
240
  var idx = queue.shift();
243
241
  var _b = decodeVoxelIndex(idx, dx, dy), x = _b[0], y = _b[1], z = _b[2];
242
+ voxels.push([x, y, z]);
244
243
  x1 = Math.min(x1, x);
245
244
  y1 = Math.min(y1, y);
246
245
  z1 = Math.min(z1, z);
@@ -248,9 +247,12 @@ export function captureShapeDraftFromClick(nv) {
248
247
  y2 = Math.max(y2, y);
249
248
  z2 = Math.max(z2, z);
250
249
  var neighbors = [
251
- [x + 1, y, z], [x - 1, y, z],
252
- [x, y + 1, z], [x, y - 1, z],
253
- [x, y, z + 1], [x, y, z - 1],
250
+ [x + 1, y, z],
251
+ [x - 1, y, z],
252
+ [x, y + 1, z],
253
+ [x, y - 1, z],
254
+ [x, y, z + 1],
255
+ [x, y, z - 1],
254
256
  ];
255
257
  for (var _i = 0, neighbors_1 = neighbors; _i < neighbors_1.length; _i++) {
256
258
  var _c = neighbors_1[_i], nx = _c[0], ny = _c[1], nz = _c[2];
@@ -265,16 +267,116 @@ export function captureShapeDraftFromClick(nv) {
265
267
  }
266
268
  if (!Number.isFinite(x1))
267
269
  return null;
268
- // baseBitmap is the bitmap with this cluster erased (so re-applying restores it)
269
- var baseBitmap = new Uint8Array(nv.drawBitmap);
270
- visited.forEach(function (idx) { baseBitmap[idx] = 0; });
270
+ return {
271
+ label: label,
272
+ visited: visited,
273
+ voxels: voxels,
274
+ bounds: { x1: x1, y1: y1, z1: z1, x2: x2, y2: y2, z2: z2 }
275
+ };
276
+ }
277
+ /** True when a voxel belongs to the live draft overlay (drawn on top of baseBitmap). */
278
+ export function isVoxelPartOfDraft(nv, draft, seedVox) {
279
+ var _a;
280
+ if (!(draft === null || draft === void 0 ? void 0 : draft.baseBitmap) || !(nv === null || nv === void 0 ? void 0 : nv.drawBitmap) || !seedVox)
281
+ return false;
282
+ var dims = (_a = nv.back) === null || _a === void 0 ? void 0 : _a.dims;
283
+ if (!dims)
284
+ return false;
285
+ var dx = dims[1];
286
+ var dy = dims[2];
287
+ var idx = voxelIndex(seedVox[0], seedVox[1], seedVox[2], dx, dy);
288
+ return (nv.drawBitmap[idx] === draft.penValue &&
289
+ draft.baseBitmap[idx] !== draft.penValue);
290
+ }
291
+ export function eraseClusterFromBitmap(bitmap, visited) {
292
+ var next = new Uint8Array(bitmap);
293
+ visited.forEach(function (idx) {
294
+ next[idx] = 0;
295
+ });
296
+ return next;
297
+ }
298
+ function sliceKey(axCorSag, x, y, z) {
299
+ if (axCorSag === 0)
300
+ return "".concat(x, ",").concat(y);
301
+ if (axCorSag === 1)
302
+ return "".concat(x, ",").concat(z);
303
+ return "".concat(y, ",").concat(z);
304
+ }
305
+ /** Guess rectangle vs ellipse from the filled voxel pattern, not the active tool. */
306
+ export function inferShapePenTypeFromCluster(cluster, axCorSag) {
307
+ var bounds = cluster.bounds, voxels = cluster.voxels;
308
+ var x1 = bounds.x1, y1 = bounds.y1, z1 = bounds.z1, x2 = bounds.x2, y2 = bounds.y2, z2 = bounds.z2;
309
+ var filled = new Set();
310
+ for (var _i = 0, voxels_1 = voxels; _i < voxels_1.length; _i++) {
311
+ var _a = voxels_1[_i], x = _a[0], y = _a[1], z = _a[2];
312
+ filled.add(sliceKey(axCorSag, x, y, z));
313
+ }
314
+ var uMin;
315
+ var uMax;
316
+ var vMin;
317
+ var vMax;
318
+ if (axCorSag === 0) {
319
+ uMin = x1;
320
+ uMax = x2;
321
+ vMin = y1;
322
+ vMax = y2;
323
+ }
324
+ else if (axCorSag === 1) {
325
+ uMin = x1;
326
+ uMax = x2;
327
+ vMin = z1;
328
+ vMax = z2;
329
+ }
330
+ else {
331
+ uMin = y1;
332
+ uMax = y2;
333
+ vMin = z1;
334
+ vMax = z2;
335
+ }
336
+ if (uMax <= uMin || vMax <= vMin) {
337
+ return NI_PEN_TYPE.RECTANGLE;
338
+ }
339
+ var cu = (uMin + uMax) / 2;
340
+ var cv = (vMin + vMax) / 2;
341
+ var ru = Math.max(0.5, (uMax - uMin) / 2);
342
+ var rv = Math.max(0.5, (vMax - vMin) / 2);
343
+ var rectScore = 0;
344
+ var ellipseScore = 0;
345
+ for (var u = uMin; u <= uMax; u++) {
346
+ for (var v = vMin; v <= vMax; v++) {
347
+ var has = filled.has("".concat(u, ",").concat(v));
348
+ var inEllipse = Math.pow(((u - cu) / ru), 2) + Math.pow(((v - cv) / rv), 2) <= 1.05;
349
+ if (has)
350
+ rectScore++;
351
+ if (has === inEllipse)
352
+ ellipseScore++;
353
+ }
354
+ }
355
+ return ellipseScore > rectScore ? NI_PEN_TYPE.ELLIPSE : NI_PEN_TYPE.RECTANGLE;
356
+ }
357
+ /**
358
+ * When the user clicks on an existing filled ROI while the rectangle/ellipse tool
359
+ * is active (but no draft is currently open), flood-fill the clicked cluster to
360
+ * reconstruct a ShapeDraft so the bounding-box overlay reappears for re-editing.
361
+ *
362
+ * Returns null if the click didn't land on a labeled voxel.
363
+ */
364
+ export function captureShapeDraftFromClick(nv) {
365
+ var seedVox = voxFromMouse(nv);
366
+ var cluster = floodFillClusterFromVox(nv, seedVox);
367
+ if (!cluster)
368
+ return null;
369
+ var label = cluster.label, visited = cluster.visited, bounds = cluster.bounds;
370
+ var x1 = bounds.x1, y1 = bounds.y1, z1 = bounds.z1, x2 = bounds.x2, y2 = bounds.y2, z2 = bounds.z2;
371
+ var baseBitmap = eraseClusterFromBitmap(nv.drawBitmap, visited);
271
372
  var axCorSag = inferAxCorSagFromBounds(x1, y1, z1, x2, y2, z2, nv.drawPenAxCorSag >= 0 ? nv.drawPenAxCorSag : 0);
373
+ var penType = inferShapePenTypeFromCluster(cluster, axCorSag);
272
374
  return {
273
375
  ptA: [x1, y1, z1],
274
376
  ptB: [x2, y2, z2],
275
377
  penValue: label,
276
378
  axCorSag: axCorSag,
277
- penType: nv.opts.penType,
379
+ penType: penType,
278
380
  baseBitmap: baseBitmap
279
381
  };
280
382
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudmr-ux",
3
- "version": "4.6.1",
3
+ "version": "4.6.3",
4
4
  "author": "erosmontin@gmail.com",
5
5
  "license": "MIT",
6
6
  "repository": "erosmontin/cloudmr-ux",