cloudmr-ux 4.8.0 → 4.8.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.
@@ -109,7 +109,7 @@ export function CloudMrNiivuePanel(props) {
109
109
  props.resampleImage();
110
110
  }, [histogram]);
111
111
  function applyDrawShapeTool(tool) {
112
- var _a, _b, _c, _d, _e;
112
+ var _a, _b, _c, _d, _e, _f;
113
113
  if (props.shapeDraft) {
114
114
  // Apply (commit) the current draft rather than discarding it
115
115
  (_a = props.onApplyShapeDraftKeepTool) === null || _a === void 0 ? void 0 : _a.call(props);
@@ -139,6 +139,15 @@ export function CloudMrNiivuePanel(props) {
139
139
  : tool === "ellipse"
140
140
  ? NI_PEN_TYPE.ELLIPSE
141
141
  : NI_PEN_TYPE.PEN;
142
+ if (tool === "rectangle" || tool === "ellipse") {
143
+ nv.opts.penBounds = 0;
144
+ nv.opts.penSize = 1;
145
+ }
146
+ else if (tool === "pen") {
147
+ var penBrush = (_f = props.drawToolkitProps.brushSize) !== null && _f !== void 0 ? _f : 1;
148
+ nv.opts.penBounds = (penBrush - 1) / 2;
149
+ nv.opts.penSize = penBrush;
150
+ }
142
151
  nv.drawScene();
143
152
  if ((tool === "rectangle" || tool === "ellipse") && !props.drawToolkitProps.drawingEnabled) {
144
153
  props.drawToolkitProps.setDrawingEnabled(true);
@@ -64,6 +64,7 @@ import { NumberPicker } from './NumberPicker';
64
64
  import { ColorPicker } from './ColorPicker';
65
65
  import { LayersPanel } from './LayersPanel';
66
66
  import { applyPenDraft, cancelPenDraft, captureFreehandDraft, fillPolylineDraft, unfillPolylineDraft, polylineDraftFromNv, syncPolylineDraftToNv, registerAppliedPolyline, restoreCommittedPolyline, collectPolylineAppliedVoxelIndices, } from './penDraftUtils';
67
+ import { registerAppliedShape, updateShapeRegistryErosionState, removeShapeRegistryEntry, clearShapeRegistry, } from './shapeDraftUtils';
67
68
  import { CloudMrNiivuePanel } from './CloudMrNiivuePanel';
68
69
  import { Niivue } from './NiivuePatcher';
69
70
  import NVSwitch from './Switch';
@@ -177,6 +178,9 @@ export default function CloudMrNiivueViewer(props) {
177
178
  var _25 = useState(0), polylineVertexCount = _25[0], setPolylineVertexCount = _25[1];
178
179
  var _26 = useState(null), penDraft = _26[0], setPenDraft = _26[1];
179
180
  var _27 = useState(1), brushSize = _27[0], setBrushSize = _27[1];
181
+ var _28 = useState(1), eraserSize = _28[0], setEraserSize = _28[1];
182
+ var brushSizeRef = React.useRef(1);
183
+ var eraserSizeRef = React.useRef(1);
180
184
  var shapeDraftRef = React.useRef(null);
181
185
  var penDraftRef = React.useRef(null);
182
186
  var drawShapeToolRef = React.useRef(null);
@@ -189,8 +193,8 @@ export default function CloudMrNiivueViewer(props) {
189
193
  nv.opts.penBounds = 0;
190
194
  nv.opts.penSize = 1;
191
195
  }, []);
192
- var _28 = React.useState(1.0), gamma = _28[0], setGamma = _28[1];
193
- var _29 = React.useState(0), gammaKey = _29[0], setGammaKey = _29[1];
196
+ var _29 = React.useState(1.0), gamma = _29[0], setGamma = _29[1];
197
+ var _30 = React.useState(0), gammaKey = _30[0], setGammaKey = _30[1];
194
198
  // Niivue → React bridge so other places (Toolbar) can force the UI to reset
195
199
  nv.onResetGamma = function () {
196
200
  setGamma(1.0);
@@ -235,13 +239,13 @@ export default function CloudMrNiivueViewer(props) {
235
239
  // setLayers([...nv.volumes])
236
240
  // }, [])
237
241
  // values dualslider
238
- var _30 = useState(0), rangeKey = _30[0], setRangeKey = _30[1];
242
+ var _31 = useState(0), rangeKey = _31[0], setRangeKey = _31[1];
239
243
  nv.onResetContrast = function () {
240
244
  setRangeKey(rangeKey + 1);
241
245
  };
242
- var _31 = useState([0, 0, 0]), boundMins = _31[0], setBoundMins = _31[1];
243
- var _32 = useState([1, 1, 1]), boundMaxs = _32[0], setBoundMaxs = _32[1];
244
- var _33 = useState([0.5, 0.5, 0.5]), mms = _33[0], setMMs = _33[1];
246
+ var _32 = useState([0, 0, 0]), boundMins = _32[0], setBoundMins = _32[1];
247
+ var _33 = useState([1, 1, 1]), boundMaxs = _33[0], setBoundMaxs = _33[1];
248
+ var _34 = useState([0.5, 0.5, 0.5]), mms = _34[0], setMMs = _34[1];
245
249
  nv.onImageLoaded = function () {
246
250
  var _a, _b;
247
251
  var oldCrosshairPos = __spreadArray([], nv.scene.crosshairPos, true);
@@ -437,6 +441,11 @@ export default function CloudMrNiivueViewer(props) {
437
441
  nv._cloudMrSuppressDrawingChangedMouseUp = false;
438
442
  return;
439
443
  }
444
+ if (nv.opts.drawingEnabled &&
445
+ nv.opts.penType === NI_PEN_TYPE.PEN &&
446
+ nv.opts.penValue === 0) {
447
+ updateShapeRegistryErosionState(nv);
448
+ }
440
449
  if (nv.opts.drawingEnabled) {
441
450
  setDrawingChanged(true);
442
451
  resampleImage();
@@ -524,7 +533,7 @@ export default function CloudMrNiivueViewer(props) {
524
533
  setTextsVisible(true);
525
534
  }
526
535
  }
527
- var _34 = useState("pan"), dragMode = _34[0], setDragMode = _34[1];
536
+ var _35 = useState("pan"), dragMode = _35[0], setDragMode = _35[1];
528
537
  function nvSetDragMode(dragMode) {
529
538
  switch (dragMode) {
530
539
  case "none":
@@ -561,11 +570,24 @@ export default function CloudMrNiivueViewer(props) {
561
570
  nv.setDrawingEnabled(enabled);
562
571
  nv.drawScene();
563
572
  }
564
- function nvUpdateBrushSize(size) {
565
- setBrushSize(size);
573
+ function applyNvBrushSize(size) {
566
574
  nv.opts.penBounds = (size - 1) / 2;
567
575
  nv.opts.penSize = size;
568
576
  }
577
+ function nvUpdateBrushSize(size) {
578
+ setBrushSize(size);
579
+ brushSizeRef.current = size;
580
+ if (drawShapeToolRef.current === "pen" && drawPen !== 0 && drawPen !== 8) {
581
+ applyNvBrushSize(size);
582
+ }
583
+ }
584
+ function nvUpdateEraserSize(size) {
585
+ setEraserSize(size);
586
+ eraserSizeRef.current = size;
587
+ if (drawPen === 0 || drawPen === 8) {
588
+ applyNvBrushSize(size);
589
+ }
590
+ }
569
591
  function nvUpdateDrawPen(a) {
570
592
  var _a;
571
593
  var raw = Number(a.target.value);
@@ -583,21 +605,13 @@ export default function CloudMrNiivueViewer(props) {
583
605
  nv.opts.deferShapeCommit = false;
584
606
  nv.opts.polylinePenMode = false;
585
607
  (_a = nv.cloudMrResetPolyline) === null || _a === void 0 ? void 0 : _a.call(nv);
586
- if (penDraftRef.current) {
587
- cancelPenDraft(nv, penDraftRef.current);
588
- setPenDraft(null);
589
- penDraftRef.current = null;
590
- nv._cloudMrPenDraftActive = false;
591
- setPolylineVertexCount(0);
592
- }
593
- if (shapeDraftRef.current) {
594
- cancelShapeDraft();
595
- }
608
+ applyActiveDraftIfAny();
596
609
  }
597
610
  else if (drawShapeToolRef.current === "pen") {
598
611
  nv.opts.deferFreehandCommit = false;
599
612
  nv.opts.polylinePenMode = penDrawModeRef.current === "polyline";
600
613
  nv.opts.isFilledPen = penDrawModeRef.current === "freehand";
614
+ applyNvBrushSize(brushSizeRef.current);
601
615
  }
602
616
  }
603
617
  function deactivateDrawTools() {
@@ -625,26 +639,17 @@ export default function CloudMrNiivueViewer(props) {
625
639
  nv.setPenValue(1, false);
626
640
  nv.opts.isFilledPen = false;
627
641
  }
642
+ applyNvBrushSize(1);
628
643
  }
629
644
  function activateEraser() {
630
- var _a;
645
+ applyActiveDraftIfAny();
631
646
  setDrawShapeTool(null);
632
647
  nv.opts.penType = NI_PEN_TYPE.PEN;
633
648
  nv.opts.deferShapeCommit = false;
634
649
  nv.opts.deferFreehandCommit = false;
635
650
  nv.opts.polylinePenMode = false;
636
- if (shapeDraftRef.current) {
637
- cancelShapeDraft();
638
- }
639
- if (penDraftRef.current) {
640
- cancelPenDraft(nv, penDraftRef.current);
641
- (_a = nv.cloudMrResetPolyline) === null || _a === void 0 ? void 0 : _a.call(nv);
642
- setPolylineVertexCount(0);
643
- setPenDraft(null);
644
- penDraftRef.current = null;
645
- nv._cloudMrPenDraftActive = false;
646
- }
647
651
  nvUpdateDrawPen({ target: { value: 8 } });
652
+ applyNvBrushSize(eraserSizeRef.current);
648
653
  nvSetDrawingEnabled(true);
649
654
  }
650
655
  function nvUpdateDrawOpacity(a) {
@@ -809,7 +814,7 @@ export default function CloudMrNiivueViewer(props) {
809
814
  nv.opts.crosshairWidth = w;
810
815
  nv.drawScene();
811
816
  }
812
- var _35 = useState({}), labelMapping = _35[0], setLabelMapping = _35[1];
817
+ var _36 = useState({}), labelMapping = _36[0], setLabelMapping = _36[1];
813
818
  function resampleImage(mapping) {
814
819
  if (mapping === void 0) { mapping = labelMapping; }
815
820
  var el = typeof document !== "undefined" ? document.getElementById("histoplot") : null;
@@ -947,6 +952,7 @@ export default function CloudMrNiivueViewer(props) {
947
952
  nv.onShapeCommitted = function (draft) {
948
953
  nv.drawAddUndoBitmap(nv.drawFillOverwrites);
949
954
  markShapeVoxelKind(draft);
955
+ registerAppliedShape(nv, draft);
950
956
  setDrawingChanged(true);
951
957
  resampleImage();
952
958
  };
@@ -1020,6 +1026,7 @@ export default function CloudMrNiivueViewer(props) {
1020
1026
  nv._cloudMrPolylineBaseBitmap = null;
1021
1027
  nv._cloudMrPolylineSessionStartBitmap = null;
1022
1028
  nv._cloudMrToolKindBitmap = null;
1029
+ clearShapeRegistry(nv);
1023
1030
  }
1024
1031
  function clearDrawingHandler() {
1025
1032
  var _a;
@@ -1057,6 +1064,9 @@ export default function CloudMrNiivueViewer(props) {
1057
1064
  var draft = shapeDraftRef.current;
1058
1065
  if (!(draft === null || draft === void 0 ? void 0 : draft.baseBitmap))
1059
1066
  return;
1067
+ if (draft._registryId != null) {
1068
+ removeShapeRegistryEntry(nv, draft._registryId);
1069
+ }
1060
1070
  // Clear tool-kind tags for voxels this shape occupied
1061
1071
  if (nv._cloudMrToolKindBitmap) {
1062
1072
  for (var i = 0; i < nv.drawBitmap.length; i++) {
@@ -1162,13 +1172,29 @@ export default function CloudMrNiivueViewer(props) {
1162
1172
  }
1163
1173
  }
1164
1174
  }
1175
+ function applyActiveDraftIfAny() {
1176
+ if (shapeDraftRef.current) {
1177
+ applyShapeDraft();
1178
+ }
1179
+ else if (penDraftRef.current) {
1180
+ applyPenDraftHandler();
1181
+ }
1182
+ }
1165
1183
  function applyShapeDraft(_a) {
1166
- var _b = _a === void 0 ? {} : _a, _c = _b.keepTool, keepTool = _c === void 0 ? false : _c;
1184
+ var _b, _c;
1185
+ var _d = _a === void 0 ? {} : _a, _e = _d.keepTool, keepTool = _e === void 0 ? false : _e;
1167
1186
  var draft = shapeDraftRef.current;
1168
1187
  if (!draft)
1169
1188
  return;
1170
1189
  nv.drawAddUndoBitmap(nv.drawFillOverwrites);
1171
1190
  markShapeVoxelKind(draft);
1191
+ var registryId = registerAppliedShape(nv, draft, {
1192
+ existingId: draft._registryId,
1193
+ eroded: ((_c = (_b = draft.shapeVoxels) === null || _b === void 0 ? void 0 : _b.length) !== null && _c !== void 0 ? _c : 0) > 0
1194
+ });
1195
+ if (registryId != null) {
1196
+ draft._registryId = registryId;
1197
+ }
1172
1198
  setDrawingChanged(true);
1173
1199
  setShapeDraft(null);
1174
1200
  shapeDraftRef.current = null;
@@ -1187,6 +1213,7 @@ export default function CloudMrNiivueViewer(props) {
1187
1213
  shapeDraftRef.current = draft;
1188
1214
  }
1189
1215
  nv.onShapeDraftReady = function (draft) {
1216
+ applyNvBrushSize(1);
1190
1217
  setShapeDraft(draft);
1191
1218
  shapeDraftRef.current = draft;
1192
1219
  nv._cloudMrShapeDraftActive = true;
@@ -1198,12 +1225,7 @@ export default function CloudMrNiivueViewer(props) {
1198
1225
  nvSetDrawingEnabled(false);
1199
1226
  };
1200
1227
  nv.onApplyActiveDraft = function () {
1201
- if (shapeDraftRef.current) {
1202
- applyShapeDraft();
1203
- }
1204
- else if (penDraftRef.current) {
1205
- applyPenDraftHandler();
1206
- }
1228
+ applyActiveDraftIfAny();
1207
1229
  };
1208
1230
  React.useEffect(function () {
1209
1231
  nv._cloudMrPenDraftActive = penDraft != null;
@@ -1259,7 +1281,7 @@ export default function CloudMrNiivueViewer(props) {
1259
1281
  setSelectionBoxColor(__spreadArray(__spreadArray([], rgb01, true), [0.5], false));
1260
1282
  nv.setSelectionBoxColor(__spreadArray(__spreadArray([], rgb01, true), [0.5], false));
1261
1283
  }
1262
- var _36 = React.useState('axial'), sliceType = _36[0], setSliceType = _36[1];
1284
+ var _37 = React.useState('axial'), sliceType = _37[0], setSliceType = _37[1];
1263
1285
  function nvUpdateSliceType(newSliceType) {
1264
1286
  setSliceType(newSliceType);
1265
1287
  if (newSliceType === 'axial') {
@@ -1399,14 +1421,14 @@ export default function CloudMrNiivueViewer(props) {
1399
1421
  return [2 /*return*/];
1400
1422
  });
1401
1423
  }); };
1402
- var _37 = useState(''), selectedROI = _37[0], setSelectedDrawingLayer = _37[1];
1403
- var _38 = useState(false), saveDialogOpen = _38[0], setSaveDialogOpen = _38[1];
1404
- var _39 = useState(function () {
1405
- }), saveConfirmCallback = _39[0], setSaveConfirmCallback = _39[1];
1406
- var _40 = useState(false), confirmationOpen = _40[0], setConfirmationOpen = _40[1];
1407
- var _41 = useState(function () { }), warningConfirmationCallback = _41[0], setWarningConfirmationCallback = _41[1];
1408
- var _42 = useState(function () { }), warningCancelCallback = _42[0], setWarningCancelCallback = _42[1];
1409
- var _43 = useState(false), drawingChanged = _43[0], setDrawingChanged = _43[1];
1424
+ var _38 = useState(''), selectedROI = _38[0], setSelectedDrawingLayer = _38[1];
1425
+ var _39 = useState(false), saveDialogOpen = _39[0], setSaveDialogOpen = _39[1];
1426
+ var _40 = useState(function () {
1427
+ }), saveConfirmCallback = _40[0], setSaveConfirmCallback = _40[1];
1428
+ var _41 = useState(false), confirmationOpen = _41[0], setConfirmationOpen = _41[1];
1429
+ var _42 = useState(function () { }), warningConfirmationCallback = _42[0], setWarningConfirmationCallback = _42[1];
1430
+ var _43 = useState(function () { }), warningCancelCallback = _43[0], setWarningCancelCallback = _43[1];
1431
+ var _44 = useState(false), drawingChanged = _44[0], setDrawingChanged = _44[1];
1410
1432
  // When new drawing strokes are made on top of a saved ROI, reset the dropdown
1411
1433
  // so it shows the "ROI Layer" placeholder instead of the stale saved name.
1412
1434
  React.useEffect(function () {
@@ -1763,6 +1785,8 @@ export default function CloudMrNiivueViewer(props) {
1763
1785
  penDraftFilled: (penDraft === null || penDraft === void 0 ? void 0 : penDraft.filled) === true,
1764
1786
  brushSize: brushSize,
1765
1787
  updateBrushSize: nvUpdateBrushSize,
1788
+ eraserSize: eraserSize,
1789
+ updateEraserSize: nvUpdateEraserSize,
1766
1790
  onActivateEraser: activateEraser,
1767
1791
  onDeactivateDrawTools: deactivateDrawTools,
1768
1792
  onClearDrawing: clearDrawingHandler
@@ -1673,6 +1673,16 @@ Niivue.prototype.mouseUpListener = function cloudMrMouseUpListener() {
1673
1673
  }
1674
1674
 
1675
1675
  if (!pendingDraft?.baseBitmap) {
1676
+ // If a draft is open and the user clicked (not dragged) on the canvas,
1677
+ // treat it as an "apply" — same behaviour as right-click or the Apply button.
1678
+ if (
1679
+ (this._cloudMrShapeDraftActive || this._cloudMrPenDraftActive) &&
1680
+ isClickWithoutDrag(this.uiData) &&
1681
+ typeof this.onApplyActiveDraft === "function"
1682
+ ) {
1683
+ this.onApplyActiveDraft();
1684
+ return;
1685
+ }
1676
1686
  cloudMrTryReopenDraftOnClick(this);
1677
1687
  return;
1678
1688
  }
@@ -28,7 +28,7 @@ var HANDLE_SIZE = 10;
28
28
  * @param {{ nv: any, draft: import('./shapeDraftUtils').ShapeDraft, onDraftChange: (d: any) => void, onApplyDraft?: () => void, overlayKey?: unknown }} props
29
29
  */
30
30
  export function ShapeDraftOverlay(_a) {
31
- var _b;
31
+ var _b, _c, _d;
32
32
  var nv = _a.nv, draft = _a.draft, onDraftChange = _a.onDraftChange, onApplyDraft = _a.onApplyDraft, overlayKey = _a.overlayKey;
33
33
  var theme = useNiivueViewerTheme();
34
34
  var dragRef = useRef(null);
@@ -38,7 +38,7 @@ export function ShapeDraftOverlay(_a) {
38
38
  var finishDragRef = useRef(null);
39
39
  var onApplyDraftRef = useRef(onApplyDraft);
40
40
  onApplyDraftRef.current = onApplyDraft;
41
- var _c = useState(0), setTick = _c[1];
41
+ var _e = useState(0), setTick = _e[1];
42
42
  var bump = useCallback(function () { return setTick(function (t) { return t + 1; }); }, []);
43
43
  useEffect(function () {
44
44
  var onMove = function () { return bump(); };
@@ -82,6 +82,7 @@ export function ShapeDraftOverlay(_a) {
82
82
  return null;
83
83
  return { left: left, top: top, width: width, height: height };
84
84
  }, [cornerCss]);
85
+ var resizeDisabled = ((_d = (_c = draft === null || draft === void 0 ? void 0 : draft.shapeVoxels) === null || _c === void 0 ? void 0 : _c.length) !== null && _d !== void 0 ? _d : 0) > 0;
85
86
  var applyDraft = useCallback(function (nextDraft) {
86
87
  redrawDraftShape(nv, nextDraft);
87
88
  onDraftChange(nextDraft);
@@ -101,6 +102,7 @@ export function ShapeDraftOverlay(_a) {
101
102
  }
102
103
  };
103
104
  onPointerMoveRef.current = function (event) {
105
+ var _a;
104
106
  var drag = dragRef.current;
105
107
  var currentDraft = draftRef.current;
106
108
  if (!drag || !currentDraft)
@@ -113,8 +115,11 @@ export function ShapeDraftOverlay(_a) {
113
115
  return;
114
116
  var startCanvas = clientToCanvasPos(canvas, drag.startClientX, drag.startClientY);
115
117
  var endCanvas = clientToCanvasPos(canvas, event.clientX, event.clientY);
116
- var delta = canvasDeltaToVoxDelta(nv, startCanvas, endCanvas);
117
- var next = __assign(__assign({}, currentDraft), { ptA: translatePt(drag.startPtA, delta), ptB: translatePt(drag.startPtB, delta) });
118
+ var delta_1 = canvasDeltaToVoxDelta(nv, startCanvas, endCanvas);
119
+ var next = __assign(__assign({}, currentDraft), { ptA: translatePt(drag.startPtA, delta_1), ptB: translatePt(drag.startPtB, delta_1), shapeVoxels: (_a = drag.startShapeVoxels) === null || _a === void 0 ? void 0 : _a.map(function (_a) {
120
+ var x = _a[0], y = _a[1], z = _a[2];
121
+ return translatePt([x, y, z], delta_1);
122
+ }) });
118
123
  applyDraft(next);
119
124
  return;
120
125
  }
@@ -123,10 +128,11 @@ export function ShapeDraftOverlay(_a) {
123
128
  if (!vox)
124
129
  return;
125
130
  var next = resizeDraftCorner(nv, currentDraft, drag.cornerIndex, vox);
126
- applyDraft(next);
131
+ applyDraft(__assign(__assign({}, next), { shapeVoxels: undefined }));
127
132
  }
128
133
  };
129
134
  var startDrag = useCallback(function (event, mode, cornerIndex) {
135
+ var _a;
130
136
  if (cornerIndex === void 0) { cornerIndex = -1; }
131
137
  event.preventDefault();
132
138
  event.stopPropagation();
@@ -137,7 +143,11 @@ export function ShapeDraftOverlay(_a) {
137
143
  startClientX: event.clientX,
138
144
  startClientY: event.clientY,
139
145
  startPtA: __spreadArray([], draft.ptA, true),
140
- startPtB: __spreadArray([], draft.ptB, true)
146
+ startPtB: __spreadArray([], draft.ptB, true),
147
+ startShapeVoxels: (_a = draft.shapeVoxels) === null || _a === void 0 ? void 0 : _a.map(function (_a) {
148
+ var x = _a[0], y = _a[1], z = _a[2];
149
+ return [x, y, z];
150
+ })
141
151
  };
142
152
  window.addEventListener("pointermove", onPointerMoveRef.current);
143
153
  window.addEventListener("pointerup", finishDragRef.current);
@@ -181,6 +191,7 @@ export function ShapeDraftOverlay(_a) {
181
191
  background: theme.accentMutedBgLight,
182
192
  boxSizing: "border-box",
183
193
  pointerEvents: "none"
184
- } }), centerCss && (_jsx("div", { role: "presentation", onPointerDown: function (e) { return startDrag(e, "move"); }, style: __assign(__assign({}, handleStyle), { left: centerCss.x, top: centerCss.y, cursor: "move", borderRadius: 2, width: 12, height: 12, marginLeft: -6, marginTop: -6 }), title: "Move shape" })), cornerCss.map(function (pos, i) { return (_jsx("div", { role: "presentation", onPointerDown: function (e) { return startDrag(e, "corner", i); }, style: __assign(__assign({}, handleStyle), { left: pos.x, top: pos.y, cursor: "nwse-resize" }), title: "Resize shape" }, "corner-".concat(i))); })] })));
194
+ } }), centerCss && (_jsx("div", { role: "presentation", onPointerDown: function (e) { return startDrag(e, "move"); }, style: __assign(__assign({}, handleStyle), { left: centerCss.x, top: centerCss.y, cursor: "move", borderRadius: 2, width: 12, height: 12, marginLeft: -6, marginTop: -6 }), title: resizeDisabled ? "Move shape (resize unavailable after erasing)" : "Move shape" })), !resizeDisabled &&
195
+ cornerCss.map(function (pos, i) { return (_jsx("div", { role: "presentation", onPointerDown: function (e) { return startDrag(e, "corner", i); }, style: __assign(__assign({}, handleStyle), { left: pos.x, top: pos.y, cursor: "nwse-resize" }), title: "Resize shape" }, "corner-".concat(i))); })] })));
185
196
  }
186
197
  export default ShapeDraftOverlay;
@@ -1,7 +1,7 @@
1
- export default function EraserPlatte({ expandEraseOptions, updateDrawPen, setDrawingEnabled, brushSize, updateBrushSize, }: {
1
+ export default function EraserPlatte({ expandEraseOptions, updateDrawPen, setDrawingEnabled, eraserSize, updateEraserSize, }: {
2
2
  expandEraseOptions: any;
3
3
  updateDrawPen: any;
4
4
  setDrawingEnabled: any;
5
- brushSize?: number | undefined;
6
- updateBrushSize: any;
5
+ eraserSize?: number | undefined;
6
+ updateEraserSize: any;
7
7
  }): import("react/jsx-runtime").JSX.Element;
@@ -15,7 +15,7 @@ import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord";
15
15
  import FiberManualRecordOutlinedIcon from "@mui/icons-material/FiberManualRecordOutlined";
16
16
  import { BrushSizeSlider } from "./BrushSizeSlider";
17
17
  export default function EraserPlatte(_a) {
18
- var expandEraseOptions = _a.expandEraseOptions, updateDrawPen = _a.updateDrawPen, setDrawingEnabled = _a.setDrawingEnabled, _b = _a.brushSize, brushSize = _b === void 0 ? 1 : _b, updateBrushSize = _a.updateBrushSize;
18
+ var expandEraseOptions = _a.expandEraseOptions, updateDrawPen = _a.updateDrawPen, setDrawingEnabled = _a.setDrawingEnabled, _b = _a.eraserSize, eraserSize = _b === void 0 ? 1 : _b, updateEraserSize = _a.updateEraserSize;
19
19
  var eraseOptions = [
20
20
  _jsx(FiberManualRecordIcon, { style: { color: "white" } }, "e0"),
21
21
  _jsx(FiberManualRecordOutlinedIcon, { style: { color: "white" } }, "e1"),
@@ -33,7 +33,7 @@ export default function EraserPlatte(_a) {
33
33
  borderTopRightRadius: "6pt",
34
34
  background: "#333",
35
35
  width: 150
36
- }, direction: "column" }, { children: [updateBrushSize && (_jsx(BrushSizeSlider, { label: "Eraser size", brushSize: brushSize, updateBrushSize: updateBrushSize })), _jsx(Stack, __assign({ direction: "row", style: { justifyContent: "center" } }, { children: eraseOptions.map(function (value, index) { return (_jsx(IconButton, __assign({ onClick: function () {
36
+ }, direction: "column" }, { children: [updateEraserSize && (_jsx(BrushSizeSlider, { label: "Eraser size", brushSize: eraserSize, updateBrushSize: updateEraserSize })), _jsx(Stack, __assign({ direction: "row", style: { justifyContent: "center" } }, { children: eraseOptions.map(function (value, index) { return (_jsx(IconButton, __assign({ onClick: function () {
37
37
  updateDrawPen({ target: { value: index === 0 ? 8 : 0 } });
38
38
  setDrawingEnabled(true);
39
39
  } }, { children: value }), index)); }) }))] })));
@@ -189,7 +189,7 @@ export function MroDrawToolkit(props) {
189
189
  alignItems: "center",
190
190
  gap: 4,
191
191
  overflow: "visible"
192
- } }, { children: [_jsxs(Box, __assign({ sx: { position: "relative", zIndex: expandedOption === "d" ? 1600 : "auto", display: "inline-flex", alignItems: "center" } }, { children: [_jsx(Tooltip, __assign({ title: "Pen" }, { children: _jsx(IconButton, __assign({ "aria-label": "pen", size: "small", onClick: clickPen, sx: __assign(__assign({}, toolBtnSx), shapeSelectedSx("pen")) }, { children: _jsx(DrawIcon, { sx: { color: "inherit" } }) })) })), _jsx(DrawColorPlatte, { expanded: expandedOption === "d", updateDrawPen: props.updateDrawPen, setDrawingEnabled: props.setDrawingEnabled, showPenModes: true, penDrawMode: props.penDrawMode, onPenDrawModeChange: props.onPenDrawModeChange, polylineVertexCount: props.polylineVertexCount, onCancelPolyline: props.onCancelPolyline, penDraftActive: props.penDraftActive, penDraftKind: props.penDraftKind, penDraftFilled: props.penDraftFilled, onApplyPenDraft: props.onApplyPenDraft, onDeletePenDraft: props.onDeletePenDraft, onFillPenDraft: props.onFillPenDraft, brushSize: props.brushSize, updateBrushSize: props.updateBrushSize })] })), _jsxs(Box, __assign({ sx: { position: "relative", zIndex: expandedOption === "r" ? 1600 : "auto", display: "inline-flex", alignItems: "center" } }, { children: [_jsx(Tooltip, __assign({ title: "Rectangle" }, { children: _jsx(IconButton, __assign({ "aria-label": "rectangle", size: "small", onClick: clickRectangle, sx: __assign(__assign({}, toolBtnSx), shapeSelectedSx("rectangle")) }, { children: _jsx(CropSquareOutlinedIcon, { sx: { color: "inherit" } }) })) })), _jsx(DrawColorPlatte, { expanded: expandedOption === "r", updateDrawPen: props.updateDrawPen, setDrawingEnabled: props.setDrawingEnabled, shapeDraftActive: props.shapeDraftActive && drawShapeTool === "rectangle", onApplyShapeDraft: props.onApplyShapeDraft, onDeleteShapeDraft: props.onDeleteShapeDraft })] })), _jsxs(Box, __assign({ sx: { position: "relative", zIndex: expandedOption === "l" ? 1600 : "auto", display: "inline-flex", alignItems: "center" } }, { children: [_jsx(Tooltip, __assign({ title: "Ellipse" }, { children: _jsx(IconButton, __assign({ "aria-label": "ellipse", size: "small", onClick: clickEllipse, sx: __assign(__assign({}, toolBtnSx), shapeSelectedSx("ellipse")) }, { children: _jsx(CircleOutlinedIcon, { sx: { color: "inherit" } }) })) })), _jsx(DrawColorPlatte, { expanded: expandedOption === "l", updateDrawPen: props.updateDrawPen, setDrawingEnabled: props.setDrawingEnabled, shapeDraftActive: props.shapeDraftActive && drawShapeTool === "ellipse", onApplyShapeDraft: props.onApplyShapeDraft, onDeleteShapeDraft: props.onDeleteShapeDraft })] })), _jsxs(Box, __assign({ sx: { position: "relative", zIndex: expandedOption === "e" ? 1600 : "auto", display: "inline-flex", alignItems: "center" } }, { children: [_jsx(Tooltip, __assign({ title: "Eraser" }, { children: _jsx(IconButton, __assign({ "aria-label": "erase", size: "small", onClick: clickEraser, sx: __assign(__assign({}, toolBtnSx), (eraserActive ? theme.selectedToolSx : {})) }, { children: filled || !eraserActive ? (_jsx(EraserIcon, {})) : (_jsx(AutoFixNormalOutlinedIcon, { sx: { color: ICON_COLOR } })) })) })), _jsx(EraserPlatte, { expandEraseOptions: expandedOption === "e", updateDrawPen: props.updateDrawPen, setDrawingEnabled: props.setDrawingEnabled, brushSize: props.brushSize, updateBrushSize: props.updateBrushSize })] })), _jsx(Tooltip, __assign({ title: "Undo" }, { children: _jsx(IconButton, __assign({ "aria-label": "revert", size: "small", onClick: function () { return props.drawUndo(); }, sx: toolBtnSx }, { children: _jsx(ReplyIcon, { sx: { color: ICON_COLOR } }) })) })), _jsx(Tooltip, __assign({ title: "Save screenshot" }, { children: _jsx("span", { children: _jsx(IconButton, __assign({ "aria-label": "capture", size: "small", disabled: !vol, onClick: function () { return vol && props.nv.saveScene("".concat(vol.name, "_drawing.png")); }, sx: toolBtnSx }, { children: _jsx(CameraAltIcon, { sx: { color: ICON_COLOR } }) })) }) })), _jsx(Tooltip, __assign({ title: "Clear drawing" }, { children: _jsx(IconButton, __assign({ "aria-label": "delete", size: "small", onClick: function () {
192
+ } }, { children: [_jsxs(Box, __assign({ sx: { position: "relative", zIndex: expandedOption === "d" ? 1600 : "auto", display: "inline-flex", alignItems: "center" } }, { children: [_jsx(Tooltip, __assign({ title: "Pen" }, { children: _jsx(IconButton, __assign({ "aria-label": "pen", size: "small", onClick: clickPen, sx: __assign(__assign({}, toolBtnSx), shapeSelectedSx("pen")) }, { children: _jsx(DrawIcon, { sx: { color: "inherit" } }) })) })), _jsx(DrawColorPlatte, { expanded: expandedOption === "d", updateDrawPen: props.updateDrawPen, setDrawingEnabled: props.setDrawingEnabled, showPenModes: true, penDrawMode: props.penDrawMode, onPenDrawModeChange: props.onPenDrawModeChange, polylineVertexCount: props.polylineVertexCount, onCancelPolyline: props.onCancelPolyline, penDraftActive: props.penDraftActive, penDraftKind: props.penDraftKind, penDraftFilled: props.penDraftFilled, onApplyPenDraft: props.onApplyPenDraft, onDeletePenDraft: props.onDeletePenDraft, onFillPenDraft: props.onFillPenDraft, brushSize: props.brushSize, updateBrushSize: props.updateBrushSize })] })), _jsxs(Box, __assign({ sx: { position: "relative", zIndex: expandedOption === "r" ? 1600 : "auto", display: "inline-flex", alignItems: "center" } }, { children: [_jsx(Tooltip, __assign({ title: "Rectangle" }, { children: _jsx(IconButton, __assign({ "aria-label": "rectangle", size: "small", onClick: clickRectangle, sx: __assign(__assign({}, toolBtnSx), shapeSelectedSx("rectangle")) }, { children: _jsx(CropSquareOutlinedIcon, { sx: { color: "inherit" } }) })) })), _jsx(DrawColorPlatte, { expanded: expandedOption === "r", updateDrawPen: props.updateDrawPen, setDrawingEnabled: props.setDrawingEnabled, shapeDraftActive: props.shapeDraftActive && drawShapeTool === "rectangle", onApplyShapeDraft: props.onApplyShapeDraft, onDeleteShapeDraft: props.onDeleteShapeDraft })] })), _jsxs(Box, __assign({ sx: { position: "relative", zIndex: expandedOption === "l" ? 1600 : "auto", display: "inline-flex", alignItems: "center" } }, { children: [_jsx(Tooltip, __assign({ title: "Ellipse" }, { children: _jsx(IconButton, __assign({ "aria-label": "ellipse", size: "small", onClick: clickEllipse, sx: __assign(__assign({}, toolBtnSx), shapeSelectedSx("ellipse")) }, { children: _jsx(CircleOutlinedIcon, { sx: { color: "inherit" } }) })) })), _jsx(DrawColorPlatte, { expanded: expandedOption === "l", updateDrawPen: props.updateDrawPen, setDrawingEnabled: props.setDrawingEnabled, shapeDraftActive: props.shapeDraftActive && drawShapeTool === "ellipse", onApplyShapeDraft: props.onApplyShapeDraft, onDeleteShapeDraft: props.onDeleteShapeDraft })] })), _jsxs(Box, __assign({ sx: { position: "relative", zIndex: expandedOption === "e" ? 1600 : "auto", display: "inline-flex", alignItems: "center" } }, { children: [_jsx(Tooltip, __assign({ title: "Eraser" }, { children: _jsx(IconButton, __assign({ "aria-label": "erase", size: "small", onClick: clickEraser, sx: __assign(__assign({}, toolBtnSx), (eraserActive ? theme.selectedToolSx : {})) }, { children: filled || !eraserActive ? (_jsx(EraserIcon, {})) : (_jsx(AutoFixNormalOutlinedIcon, { sx: { color: ICON_COLOR } })) })) })), _jsx(EraserPlatte, { expandEraseOptions: expandedOption === "e", updateDrawPen: props.updateDrawPen, setDrawingEnabled: props.setDrawingEnabled, eraserSize: props.eraserSize, updateEraserSize: props.updateEraserSize })] })), _jsx(Tooltip, __assign({ title: "Undo" }, { children: _jsx(IconButton, __assign({ "aria-label": "revert", size: "small", onClick: function () { return props.drawUndo(); }, sx: toolBtnSx }, { children: _jsx(ReplyIcon, { sx: { color: ICON_COLOR } }) })) })), _jsx(Tooltip, __assign({ title: "Save screenshot" }, { children: _jsx("span", { children: _jsx(IconButton, __assign({ "aria-label": "capture", size: "small", disabled: !vol, onClick: function () { return vol && props.nv.saveScene("".concat(vol.name, "_drawing.png")); }, sx: toolBtnSx }, { children: _jsx(CameraAltIcon, { sx: { color: ICON_COLOR } }) })) }) })), _jsx(Tooltip, __assign({ title: "Clear drawing" }, { children: _jsx(IconButton, __assign({ "aria-label": "delete", size: "small", onClick: function () {
193
193
  var _a;
194
194
  (_a = props.onClearDrawing) === null || _a === void 0 ? void 0 : _a.call(props);
195
195
  setExpandedOption("n");
@@ -7,7 +7,29 @@
7
7
  * @property {number} axCorSag
8
8
  * @property {number} penType
9
9
  * @property {Uint8Array} baseBitmap
10
+ * @property {[number, number, number][]} [shapeVoxels] exact voxels when reopening a modified shape
11
+ * @property {number} [_registryId] links to _cloudMrShapeRegistry for erosion tracking
10
12
  */
13
+ export function collectShapeVoxelIndices(nv: any, draft: any): Set<any>;
14
+ export function findShapeRegistryEntryById(nv: any, registryId: any): any;
15
+ /**
16
+ * Match a flood-filled cluster to a registered shape using an IoU-style score:
17
+ * overlap / max(clusterSize, entrySize)
18
+ * This prevents a large eroded rectangle from matching a smaller new shape drawn
19
+ * inside its original footprint, while still correctly identifying the same shape
20
+ * being re-opened (where both sizes are nearly equal and overlap is near 1.0).
21
+ */
22
+ export function findShapeRegistryEntry(nv: any, cluster: any): any;
23
+ /** Persist applied rectangle/ellipse voxels so eraser hits can be tracked per shape. */
24
+ export function registerAppliedShape(nv: any, draft: any, { existingId, eroded }?: {
25
+ existingId: any;
26
+ eroded?: boolean | undefined;
27
+ }): any;
28
+ /** Mark shapes whose stored voxels were cleared by the eraser. */
29
+ export function updateShapeRegistryErosionState(nv: any): void;
30
+ export function removeShapeRegistryEntry(nv: any, registryId: any): void;
31
+ export function clearShapeRegistry(nv: any): void;
32
+ export function paintShapeVoxels(nv: any, voxels: any, penValue: any): void;
11
33
  export function penTypeToKind(penType: any): "rectangle" | "ellipse";
12
34
  export function normalizeBounds(ptA: any, ptB: any, dims: any): {
13
35
  x1: number;
@@ -70,6 +92,8 @@ export function eraseClusterFromBitmap(bitmap: any, visited: any): Uint8Array;
70
92
  * reconstruct a ShapeDraft so the bounding-box overlay reappears for re-editing.
71
93
  */
72
94
  export function captureShapeDraftFromClick(nv: any): {
95
+ shapeVoxels?: number[][] | undefined;
96
+ _registryId?: any;
73
97
  ptA: any[];
74
98
  ptB: any[];
75
99
  penValue: number;
@@ -85,4 +109,12 @@ export type ShapeDraft = {
85
109
  axCorSag: number;
86
110
  penType: number;
87
111
  baseBitmap: Uint8Array;
112
+ /**
113
+ * exact voxels when reopening a modified shape
114
+ */
115
+ shapeVoxels?: [number, number, number][] | undefined;
116
+ /**
117
+ * links to _cloudMrShapeRegistry for erosion tracking
118
+ */
119
+ _registryId?: number | undefined;
88
120
  };
@@ -29,7 +29,136 @@ import { voxFromMouse } from "./polylinePenUtils";
29
29
  * @property {number} axCorSag
30
30
  * @property {number} penType
31
31
  * @property {Uint8Array} baseBitmap
32
+ * @property {[number, number, number][]} [shapeVoxels] exact voxels when reopening a modified shape
33
+ * @property {number} [_registryId] links to _cloudMrShapeRegistry for erosion tracking
32
34
  */
35
+ export function collectShapeVoxelIndices(nv, draft) {
36
+ var _a, _b;
37
+ var indices = new Set();
38
+ if (!(nv === null || nv === void 0 ? void 0 : nv.drawBitmap))
39
+ return indices;
40
+ var dims = (_a = nv.back) === null || _a === void 0 ? void 0 : _a.dims;
41
+ if (((_b = draft.shapeVoxels) === null || _b === void 0 ? void 0 : _b.length) && dims) {
42
+ var dx = dims[1];
43
+ var dy = dims[2];
44
+ for (var _i = 0, _c = draft.shapeVoxels; _i < _c.length; _i++) {
45
+ var _d = _c[_i], x = _d[0], y = _d[1], z = _d[2];
46
+ indices.add(x + y * dx + z * dx * dy);
47
+ }
48
+ return indices;
49
+ }
50
+ if (!draft.baseBitmap)
51
+ return indices;
52
+ for (var i = 0; i < nv.drawBitmap.length; i++) {
53
+ if (nv.drawBitmap[i] === draft.penValue && draft.baseBitmap[i] !== draft.penValue) {
54
+ indices.add(i);
55
+ }
56
+ }
57
+ return indices;
58
+ }
59
+ export function findShapeRegistryEntryById(nv, registryId) {
60
+ var _a, _b;
61
+ return (_b = (_a = nv._cloudMrShapeRegistry) === null || _a === void 0 ? void 0 : _a.find(function (entry) { return entry.id === registryId; })) !== null && _b !== void 0 ? _b : null;
62
+ }
63
+ /**
64
+ * Match a flood-filled cluster to a registered shape using an IoU-style score:
65
+ * overlap / max(clusterSize, entrySize)
66
+ * This prevents a large eroded rectangle from matching a smaller new shape drawn
67
+ * inside its original footprint, while still correctly identifying the same shape
68
+ * being re-opened (where both sizes are nearly equal and overlap is near 1.0).
69
+ */
70
+ export function findShapeRegistryEntry(nv, cluster) {
71
+ var registry = nv._cloudMrShapeRegistry;
72
+ if (!(registry === null || registry === void 0 ? void 0 : registry.length) || !(cluster === null || cluster === void 0 ? void 0 : cluster.visited))
73
+ return null;
74
+ var clusterSize = cluster.visited.size;
75
+ if (clusterSize === 0)
76
+ return null;
77
+ var MATCH_THRESHOLD = 0.5;
78
+ var best = null;
79
+ var bestScore = 0;
80
+ for (var _i = 0, registry_1 = registry; _i < registry_1.length; _i++) {
81
+ var entry = registry_1[_i];
82
+ var overlap = 0;
83
+ for (var _a = 0, _b = cluster.visited; _a < _b.length; _a++) {
84
+ var idx = _b[_a];
85
+ if (entry.voxelIndices.has(idx))
86
+ overlap++;
87
+ }
88
+ var score = overlap / Math.max(clusterSize, entry.voxelIndices.size);
89
+ if (score > bestScore) {
90
+ bestScore = score;
91
+ best = entry;
92
+ }
93
+ }
94
+ return bestScore >= MATCH_THRESHOLD ? best : null;
95
+ }
96
+ /** Persist applied rectangle/ellipse voxels so eraser hits can be tracked per shape. */
97
+ export function registerAppliedShape(nv, draft, _a) {
98
+ var _b, _c;
99
+ var _d = _a === void 0 ? {} : _a, existingId = _d.existingId, _e = _d.eroded, eroded = _e === void 0 ? false : _e;
100
+ var voxelIndices = collectShapeVoxelIndices(nv, draft);
101
+ if (!voxelIndices.size)
102
+ return null;
103
+ nv._cloudMrShapeRegistry = nv._cloudMrShapeRegistry || [];
104
+ var nextId = existingId !== null && existingId !== void 0 ? existingId : (nv._cloudMrShapeNextId = (nv._cloudMrShapeNextId || 0) + 1);
105
+ var entry = {
106
+ id: nextId,
107
+ ptA: __spreadArray([], draft.ptA, true),
108
+ ptB: __spreadArray([], draft.ptB, true),
109
+ penType: draft.penType,
110
+ penValue: draft.penValue,
111
+ axCorSag: draft.axCorSag,
112
+ voxelIndices: voxelIndices,
113
+ eroded: eroded || ((_c = (_b = draft.shapeVoxels) === null || _b === void 0 ? void 0 : _b.length) !== null && _c !== void 0 ? _c : 0) > 0
114
+ };
115
+ var existingIndex = nv._cloudMrShapeRegistry.findIndex(function (e) { return e.id === nextId; });
116
+ if (existingIndex >= 0) {
117
+ nv._cloudMrShapeRegistry[existingIndex] = entry;
118
+ }
119
+ else {
120
+ nv._cloudMrShapeRegistry.push(entry);
121
+ }
122
+ return nextId;
123
+ }
124
+ /** Mark shapes whose stored voxels were cleared by the eraser. */
125
+ export function updateShapeRegistryErosionState(nv) {
126
+ var registry = nv._cloudMrShapeRegistry;
127
+ if (!(registry === null || registry === void 0 ? void 0 : registry.length) || !nv.drawBitmap)
128
+ return;
129
+ for (var _i = 0, registry_2 = registry; _i < registry_2.length; _i++) {
130
+ var entry = registry_2[_i];
131
+ if (entry.eroded)
132
+ continue;
133
+ for (var _a = 0, _b = entry.voxelIndices; _a < _b.length; _a++) {
134
+ var idx = _b[_a];
135
+ if (nv.drawBitmap[idx] === 0) {
136
+ entry.eroded = true;
137
+ break;
138
+ }
139
+ }
140
+ }
141
+ }
142
+ export function removeShapeRegistryEntry(nv, registryId) {
143
+ if (registryId == null || !nv._cloudMrShapeRegistry)
144
+ return;
145
+ nv._cloudMrShapeRegistry = nv._cloudMrShapeRegistry.filter(function (e) { return e.id !== registryId; });
146
+ }
147
+ export function clearShapeRegistry(nv) {
148
+ nv._cloudMrShapeRegistry = [];
149
+ }
150
+ export function paintShapeVoxels(nv, voxels, penValue) {
151
+ var _a;
152
+ var dims = (_a = nv.back) === null || _a === void 0 ? void 0 : _a.dims;
153
+ if (!dims || !(voxels === null || voxels === void 0 ? void 0 : voxels.length))
154
+ return;
155
+ var dx = dims[1];
156
+ var dy = dims[2];
157
+ for (var _i = 0, voxels_1 = voxels; _i < voxels_1.length; _i++) {
158
+ var _b = voxels_1[_i], x = _b[0], y = _b[1], z = _b[2];
159
+ nv.drawBitmap[x + y * dx + z * dx * dy] = penValue;
160
+ }
161
+ }
33
162
  export function penTypeToKind(penType) {
34
163
  return penType === NI_PEN_TYPE.ELLIPSE ? "ellipse" : "rectangle";
35
164
  }
@@ -88,16 +217,27 @@ export function isDraftTooSmall(ptA, ptB) {
88
217
  }
89
218
  /** @param {ShapeDraft} draft */
90
219
  export function redrawDraftShape(nv, draft) {
220
+ var _a;
91
221
  if (!(nv === null || nv === void 0 ? void 0 : nv.drawBitmap) || !(draft === null || draft === void 0 ? void 0 : draft.baseBitmap))
92
222
  return;
223
+ // Shapes are filled regions — penSize/penBounds must not expand voxels (e.g. after eraser).
224
+ var savedPenBounds = nv.opts.penBounds;
225
+ var savedPenSize = nv.opts.penSize;
226
+ nv.opts.penBounds = 0;
227
+ nv.opts.penSize = 1;
93
228
  nv.drawBitmap.set(draft.baseBitmap);
94
229
  nv.drawPenAxCorSag = draft.axCorSag;
95
- if (draft.penType === NI_PEN_TYPE.RECTANGLE) {
230
+ if ((_a = draft.shapeVoxels) === null || _a === void 0 ? void 0 : _a.length) {
231
+ paintShapeVoxels(nv, draft.shapeVoxels, draft.penValue);
232
+ }
233
+ else if (draft.penType === NI_PEN_TYPE.RECTANGLE) {
96
234
  nv.drawRectangleMask(draft.ptA, draft.ptB, draft.penValue);
97
235
  }
98
236
  else {
99
237
  nv.drawEllipseMask(draft.ptA, draft.ptB, draft.penValue);
100
238
  }
239
+ nv.opts.penBounds = savedPenBounds;
240
+ nv.opts.penSize = savedPenSize;
101
241
  nv.refreshDrawing(false, false);
102
242
  nv.drawScene();
103
243
  }
@@ -312,8 +452,8 @@ function inferShapePenTypeFromCluster(cluster, axCorSag) {
312
452
  var bounds = cluster.bounds, voxels = cluster.voxels;
313
453
  var x1 = bounds.x1, y1 = bounds.y1, z1 = bounds.z1, x2 = bounds.x2, y2 = bounds.y2, z2 = bounds.z2;
314
454
  var filled = new Set();
315
- for (var _i = 0, voxels_1 = voxels; _i < voxels_1.length; _i++) {
316
- var _a = voxels_1[_i], x = _a[0], y = _a[1], z = _a[2];
455
+ for (var _i = 0, voxels_2 = voxels; _i < voxels_2.length; _i++) {
456
+ var _a = voxels_2[_i], x = _a[0], y = _a[1], z = _a[2];
317
457
  filled.add(sliceKey(axCorSag, x, y, z));
318
458
  }
319
459
  var uMin;
@@ -359,6 +499,59 @@ function inferShapePenTypeFromCluster(cluster, axCorSag) {
359
499
  }
360
500
  return ellipseScore > rectScore ? NI_PEN_TYPE.ELLIPSE : NI_PEN_TYPE.RECTANGLE;
361
501
  }
502
+ /**
503
+ * Geometric completeness check: true when the cluster still fills its bounding
504
+ * shape with no significant gaps (i.e. the eraser has not meaningfully touched it).
505
+ * Used as a fallback when the shape is not in the registry.
506
+ *
507
+ * For rectangles: all bounding-box voxels must be present.
508
+ * For ellipses: we use niivue's actual threshold (≤ 1.0) so boundary pixels
509
+ * that niivue doesn't draw never trigger a false "eroded" result.
510
+ * Additionally we allow up to 5% of interior voxels to be absent
511
+ * to tolerate rendering rounding without masking real erasure.
512
+ */
513
+ function isClusterCompleteShape(cluster, penType) {
514
+ var bounds = cluster.bounds, voxels = cluster.voxels;
515
+ var x1 = bounds.x1, y1 = bounds.y1, z1 = bounds.z1, x2 = bounds.x2, y2 = bounds.y2, z2 = bounds.z2;
516
+ var filled = new Set(voxels.map(function (_a) {
517
+ var x = _a[0], y = _a[1], z = _a[2];
518
+ return "".concat(x, ",").concat(y, ",").concat(z);
519
+ }));
520
+ if (penType === NI_PEN_TYPE.RECTANGLE) {
521
+ for (var z = z1; z <= z2; z++) {
522
+ for (var y = y1; y <= y2; y++) {
523
+ for (var x = x1; x <= x2; x++) {
524
+ if (!filled.has("".concat(x, ",").concat(y, ",").concat(z)))
525
+ return false;
526
+ }
527
+ }
528
+ }
529
+ return true;
530
+ }
531
+ // Ellipse / ellipsoid: match niivue's exact threshold (≤ 1.0).
532
+ var cx = (x1 + x2) / 2;
533
+ var cy = (y1 + y2) / 2;
534
+ var cz = (z1 + z2) / 2;
535
+ var ru = Math.max(0.5, (x2 - x1) / 2);
536
+ var rv = Math.max(0.5, (y2 - y1) / 2);
537
+ var rw = Math.max(0.5, (z2 - z1) / 2);
538
+ var expected = 0;
539
+ var missing = 0;
540
+ for (var z = z1; z <= z2; z++) {
541
+ for (var y = y1; y <= y2; y++) {
542
+ for (var x = x1; x <= x2; x++) {
543
+ var inEllipse = Math.pow(((x - cx) / ru), 2) + Math.pow(((y - cy) / rv), 2) + Math.pow(((z - cz) / rw), 2) <= 1.0;
544
+ if (inEllipse) {
545
+ expected++;
546
+ if (!filled.has("".concat(x, ",").concat(y, ",").concat(z)))
547
+ missing++;
548
+ }
549
+ }
550
+ }
551
+ }
552
+ // Allow up to 5% missing to handle minor rendering rounding at the boundary.
553
+ return expected === 0 || missing / expected <= 0.05;
554
+ }
362
555
  /**
363
556
  * When the user clicks on an existing filled ROI while the rectangle/ellipse tool
364
557
  * is active (but no draft is currently open), flood-fill the clicked cluster to
@@ -369,17 +562,21 @@ export function captureShapeDraftFromClick(nv) {
369
562
  var cluster = floodFillClusterFromVox(nv, seedVox);
370
563
  if (!cluster)
371
564
  return null;
372
- var label = cluster.label, visited = cluster.visited, bounds = cluster.bounds;
565
+ var label = cluster.label, visited = cluster.visited, voxels = cluster.voxels, bounds = cluster.bounds;
373
566
  var x1 = bounds.x1, y1 = bounds.y1, z1 = bounds.z1, x2 = bounds.x2, y2 = bounds.y2, z2 = bounds.z2;
374
567
  var baseBitmap = eraseClusterFromBitmap(nv.drawBitmap, visited);
375
568
  var axCorSag = inferAxCorSagFromBounds(x1, y1, z1, x2, y2, z2, nv.drawPenAxCorSag >= 0 ? nv.drawPenAxCorSag : 0);
376
569
  var penType = inferShapePenTypeFromCluster(cluster, axCorSag);
377
- return {
378
- ptA: [x1, y1, z1],
379
- ptB: [x2, y2, z2],
380
- penValue: label,
381
- axCorSag: axCorSag,
382
- penType: penType,
383
- baseBitmap: baseBitmap
384
- };
570
+ var registryEntry = findShapeRegistryEntry(nv, cluster);
571
+ // A shape is considered modified (partially erased) if:
572
+ // - the registry knows it was eroded, OR
573
+ // - (no registry entry) the geometry check finds gaps
574
+ var isModified = (registryEntry === null || registryEntry === void 0 ? void 0 : registryEntry.eroded) === true ||
575
+ (!registryEntry && !isClusterCompleteShape(cluster, penType));
576
+ return __assign(__assign({ ptA: [x1, y1, z1], ptB: [x2, y2, z2], penValue: label, axCorSag: axCorSag, penType: penType, baseBitmap: baseBitmap }, (registryEntry ? { _registryId: registryEntry.id } : {})), (isModified
577
+ ? { shapeVoxels: voxels.map(function (_a) {
578
+ var x = _a[0], y = _a[1], z = _a[2];
579
+ return [x, y, z];
580
+ }) }
581
+ : {}));
385
582
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudmr-ux",
3
- "version": "4.8.0",
3
+ "version": "4.8.2",
4
4
  "author": "erosmontin@gmail.com",
5
5
  "license": "MIT",
6
6
  "repository": "erosmontin/cloudmr-ux",