copper3d 3.1.2 → 3.2.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.
Files changed (62) hide show
  1. package/dist/Utils/segmentation/DragOperator.d.ts +2 -1
  2. package/dist/Utils/segmentation/DragOperator.js +6 -2
  3. package/dist/Utils/segmentation/DragOperator.js.map +1 -1
  4. package/dist/Utils/segmentation/DrawToolCore.d.ts +6 -0
  5. package/dist/Utils/segmentation/DrawToolCore.js +107 -35
  6. package/dist/Utils/segmentation/DrawToolCore.js.map +1 -1
  7. package/dist/Utils/segmentation/NrrdTools.d.ts +5 -1
  8. package/dist/Utils/segmentation/NrrdTools.js +87 -3
  9. package/dist/Utils/segmentation/NrrdTools.js.map +1 -1
  10. package/dist/Utils/segmentation/core/GaussianSmoother.d.ts +55 -0
  11. package/dist/Utils/segmentation/core/GaussianSmoother.js +210 -0
  12. package/dist/Utils/segmentation/core/GaussianSmoother.js.map +1 -0
  13. package/dist/Utils/segmentation/core/MaskVolume.d.ts +9 -0
  14. package/dist/Utils/segmentation/core/MaskVolume.js +11 -0
  15. package/dist/Utils/segmentation/core/MaskVolume.js.map +1 -1
  16. package/dist/Utils/segmentation/core/UndoManager.d.ts +9 -5
  17. package/dist/Utils/segmentation/core/UndoManager.js +21 -14
  18. package/dist/Utils/segmentation/core/UndoManager.js.map +1 -1
  19. package/dist/Utils/segmentation/core/index.d.ts +1 -1
  20. package/dist/Utils/segmentation/core/types.d.ts +5 -1
  21. package/dist/Utils/segmentation/core/types.js.map +1 -1
  22. package/dist/Utils/segmentation/coreTools/GuiState.js +2 -0
  23. package/dist/Utils/segmentation/coreTools/GuiState.js.map +1 -1
  24. package/dist/Utils/segmentation/coreTools/NrrdState.js +2 -0
  25. package/dist/Utils/segmentation/coreTools/NrrdState.js.map +1 -1
  26. package/dist/Utils/segmentation/eventRouter/EventRouter.js +1 -0
  27. package/dist/Utils/segmentation/eventRouter/EventRouter.js.map +1 -1
  28. package/dist/Utils/segmentation/eventRouter/types.d.ts +1 -1
  29. package/dist/Utils/segmentation/tools/DragSliceTool.d.ts +0 -4
  30. package/dist/Utils/segmentation/tools/DragSliceTool.js +2 -19
  31. package/dist/Utils/segmentation/tools/DragSliceTool.js.map +1 -1
  32. package/dist/Utils/segmentation/tools/SliceRenderPipeline.js +8 -1
  33. package/dist/Utils/segmentation/tools/SliceRenderPipeline.js.map +1 -1
  34. package/dist/Utils/segmentation/tools/SphereBrushTool.d.ts +127 -0
  35. package/dist/Utils/segmentation/tools/SphereBrushTool.js +489 -0
  36. package/dist/Utils/segmentation/tools/SphereBrushTool.js.map +1 -0
  37. package/dist/Utils/segmentation/tools/ToolHost.d.ts +4 -1
  38. package/dist/Utils/segmentation/tools/ZoomTool.js +6 -1
  39. package/dist/Utils/segmentation/tools/ZoomTool.js.map +1 -1
  40. package/dist/Utils/segmentation/tools/index.d.ts +2 -1
  41. package/dist/Utils/segmentation/tools/index.js +1 -0
  42. package/dist/Utils/segmentation/tools/index.js.map +1 -1
  43. package/dist/bundle.esm.js +950 -77
  44. package/dist/bundle.umd.js +950 -76
  45. package/dist/index.d.ts +3 -2
  46. package/dist/index.js +3 -3
  47. package/dist/index.js.map +1 -1
  48. package/dist/types/Utils/segmentation/DragOperator.d.ts +2 -1
  49. package/dist/types/Utils/segmentation/DrawToolCore.d.ts +6 -0
  50. package/dist/types/Utils/segmentation/NrrdTools.d.ts +5 -1
  51. package/dist/types/Utils/segmentation/core/GaussianSmoother.d.ts +55 -0
  52. package/dist/types/Utils/segmentation/core/MaskVolume.d.ts +9 -0
  53. package/dist/types/Utils/segmentation/core/UndoManager.d.ts +9 -5
  54. package/dist/types/Utils/segmentation/core/index.d.ts +1 -1
  55. package/dist/types/Utils/segmentation/core/types.d.ts +5 -1
  56. package/dist/types/Utils/segmentation/eventRouter/types.d.ts +1 -1
  57. package/dist/types/Utils/segmentation/tools/DragSliceTool.d.ts +0 -4
  58. package/dist/types/Utils/segmentation/tools/SphereBrushTool.d.ts +127 -0
  59. package/dist/types/Utils/segmentation/tools/ToolHost.d.ts +4 -1
  60. package/dist/types/Utils/segmentation/tools/index.d.ts +2 -1
  61. package/dist/types/index.d.ts +3 -2
  62. package/package.json +1 -1
@@ -71387,6 +71387,17 @@ class MaskVolume {
71387
71387
  getChannels() {
71388
71388
  return this.numChannels;
71389
71389
  }
71390
+ /**
71391
+ * Return the number of bytes in one complete z-slice.
71392
+ *
71393
+ * Equal to `width × height × channels`. Useful for direct buffer
71394
+ * index arithmetic in performance-critical code paths.
71395
+ *
71396
+ * @returns Bytes per z-slice.
71397
+ */
71398
+ getBytesPerSlice() {
71399
+ return this.bytesPerSlice;
71400
+ }
71390
71401
  /**
71391
71402
  * Return total memory used by the backing buffer, in bytes.
71392
71403
  *
@@ -71955,41 +71966,48 @@ class UndoManager {
71955
71966
  setActiveLayer(layer) {
71956
71967
  this.activeLayer = layer;
71957
71968
  }
71958
- /** Push a delta onto the active layer's undo stack and clear the redo stack. */
71969
+ /** Push a single delta onto the active layer's undo stack and clear the redo stack. */
71959
71970
  push(delta) {
71971
+ this.pushGroup([delta]);
71972
+ }
71973
+ /** Push a group of deltas as a single undo unit (e.g. 3D sphere spanning multiple slices). */
71974
+ pushGroup(deltas) {
71960
71975
  var _a, _b;
71961
- const stack = (_a = this.undoStacks.get(delta.layerId)) !== null && _a !== void 0 ? _a : this.undoStacks.get("layer1");
71962
- stack.push(delta);
71976
+ if (deltas.length === 0)
71977
+ return;
71978
+ const layerId = deltas[0].layerId;
71979
+ const stack = (_a = this.undoStacks.get(layerId)) !== null && _a !== void 0 ? _a : this.undoStacks.get("layer1");
71980
+ stack.push(deltas);
71963
71981
  if (stack.length > MAX_STACK_SIZE) {
71964
71982
  stack.shift();
71965
71983
  }
71966
71984
  // Any new operation invalidates the redo history for that layer
71967
- const redoStack = (_b = this.redoStacks.get(delta.layerId)) !== null && _b !== void 0 ? _b : this.redoStacks.get("layer1");
71985
+ const redoStack = (_b = this.redoStacks.get(layerId)) !== null && _b !== void 0 ? _b : this.redoStacks.get("layer1");
71968
71986
  redoStack.length = 0;
71969
71987
  }
71970
71988
  /**
71971
71989
  * Undo the last operation on the active layer.
71972
- * @returns The delta that was undone, or undefined if nothing to undo.
71990
+ * @returns The delta(s) that were undone, or undefined if nothing to undo.
71973
71991
  */
71974
71992
  undo() {
71975
71993
  const stack = this.undoStacks.get(this.activeLayer);
71976
- const delta = stack.pop();
71977
- if (delta) {
71978
- this.redoStacks.get(this.activeLayer).push(delta);
71994
+ const entry = stack.pop();
71995
+ if (entry) {
71996
+ this.redoStacks.get(this.activeLayer).push(entry);
71979
71997
  }
71980
- return delta;
71998
+ return entry;
71981
71999
  }
71982
72000
  /**
71983
72001
  * Redo the last undone operation on the active layer.
71984
- * @returns The delta that was redone, or undefined if nothing to redo.
72002
+ * @returns The delta(s) that were redone, or undefined if nothing to redo.
71985
72003
  */
71986
72004
  redo() {
71987
72005
  const stack = this.redoStacks.get(this.activeLayer);
71988
- const delta = stack.pop();
71989
- if (delta) {
71990
- this.undoStacks.get(this.activeLayer).push(delta);
72006
+ const entry = stack.pop();
72007
+ if (entry) {
72008
+ this.undoStacks.get(this.activeLayer).push(entry);
71991
72009
  }
71992
- return delta;
72010
+ return entry;
71993
72011
  }
71994
72012
  canUndo() {
71995
72013
  var _a, _b;
@@ -73197,8 +73215,8 @@ class DragSliceTool extends BaseTool {
73197
73215
  }
73198
73216
  }
73199
73217
  }
73200
- // Composite all layers to master canvas
73201
- this.compositeAllLayers();
73218
+ // Composite all layers to master canvas (uses per-layer alpha)
73219
+ this.callbacks.compositeAllLayers();
73202
73220
  }
73203
73221
  // Refresh sphere overlay from volume for the new slice
73204
73222
  if (this.ctx.gui_states.mode.sphere) {
@@ -73207,23 +73225,6 @@ class DragSliceTool extends BaseTool {
73207
73225
  view.switchSliceFlag = false;
73208
73226
  }
73209
73227
  }
73210
- /**
73211
- * Composite all visible layer canvases to the master display canvas.
73212
- */
73213
- compositeAllLayers() {
73214
- const masterCtx = this.ctx.protectedData.ctxes.drawingLayerMasterCtx;
73215
- const width = this.ctx.nrrd_states.view.changedWidth;
73216
- const height = this.ctx.nrrd_states.view.changedHeight;
73217
- masterCtx.clearRect(0, 0, width, height);
73218
- // Master stores full-alpha composite; globalAlpha applied in start() render loop.
73219
- for (const layerId of this.ctx.nrrd_states.image.layers) {
73220
- if (!this.ctx.gui_states.layerChannel.layerVisibility[layerId])
73221
- continue;
73222
- const target = this.ctx.protectedData.layerTargets.get(layerId);
73223
- if (target)
73224
- masterCtx.drawImage(target.canvas, 0, 0, width, height);
73225
- }
73226
- }
73227
73228
  // ===== Canvas Cleanup =====
73228
73229
  cleanCanvases(flag) {
73229
73230
  if (flag) {
@@ -73265,7 +73266,7 @@ class DragSliceTool extends BaseTool {
73265
73266
  }
73266
73267
 
73267
73268
  class DragOperator {
73268
- constructor(container, nrrd_sates, gui_states, protectedData, drawingPrameters, setSyncsliceNum, setIsDrawFalse, flipDisplayImageByAxis, setEmptyCanvasSize, getOrCreateSliceBuffer, renderSliceToCanvas) {
73269
+ constructor(container, nrrd_sates, gui_states, protectedData, drawingPrameters, setSyncsliceNum, setIsDrawFalse, flipDisplayImageByAxis, setEmptyCanvasSize, getOrCreateSliceBuffer, renderSliceToCanvas, compositeAllLayers) {
73269
73270
  this.dragPrameters = {
73270
73271
  move: 0,
73271
73272
  y: 0,
@@ -73301,6 +73302,7 @@ class DragOperator {
73301
73302
  this.setEmptyCanvasSize = setEmptyCanvasSize;
73302
73303
  this.getOrCreateSliceBuffer = getOrCreateSliceBuffer;
73303
73304
  this.renderSliceToCanvas = renderSliceToCanvas;
73305
+ this.compositeAllLayers = compositeAllLayers;
73304
73306
  this.showDragNumberDiv = createShowSliceNumberDiv();
73305
73307
  this.init();
73306
73308
  }
@@ -73326,6 +73328,7 @@ class DragOperator {
73326
73328
  setEmptyCanvasSize: (axis) => this.setEmptyCanvasSize(axis),
73327
73329
  getOrCreateSliceBuffer: (axis) => this.getOrCreateSliceBuffer(axis),
73328
73330
  renderSliceToCanvas: (layer, axis, sliceIndex, buffer, targetCtx, w, h) => this.renderSliceToCanvas(layer, axis, sliceIndex, buffer, targetCtx, w, h),
73331
+ compositeAllLayers: () => this.compositeAllLayers(),
73329
73332
  refreshSphereOverlay: () => { var _a; return (_a = this.refreshSphereOverlayCb) === null || _a === void 0 ? void 0 : _a.call(this); },
73330
73333
  }, this.showDragNumberDiv, dragEffectCanvases);
73331
73334
  }
@@ -73358,7 +73361,9 @@ class DragOperator {
73358
73361
  // When leaving draw, contrast, or crosshair mode (returning to idle), restore drag mode
73359
73362
  // Do NOT restore if sphere mode is active
73360
73363
  if ((prev === 'draw' || prev === 'contrast' || prev === 'crosshair') && next === 'idle') {
73361
- if (!this.gui_states.mode.sphere) {
73364
+ if (!this.gui_states.mode.sphere
73365
+ && !this.gui_states.mode.sphereBrush
73366
+ && !this.gui_states.mode.sphereEraser) {
73362
73367
  this.configDragMode();
73363
73368
  }
73364
73369
  }
@@ -73897,6 +73902,7 @@ class EventRouter {
73897
73902
  if (ev.key === this.keyboardSettings.draw) {
73898
73903
  this.state.shiftHeld = true;
73899
73904
  // Block draw mode when crosshair or contrast is active (mutual exclusion)
73905
+ // Also block when sphereBrush or sphereEraser is active
73900
73906
  if (DRAWING_TOOLS.has(this.guiTool) && !this.state.ctrlHeld && !this.state.crosshairEnabled) {
73901
73907
  this.setMode('draw');
73902
73908
  }
@@ -74296,10 +74302,15 @@ class ZoomTool extends BaseTool {
74296
74302
  configMouseZoomWheel() {
74297
74303
  let moveDistance = 1;
74298
74304
  return (e) => {
74299
- var _a;
74305
+ var _a, _b;
74300
74306
  if ((_a = this.ctx.eventRouter) === null || _a === void 0 ? void 0 : _a.isShiftHeld()) {
74301
74307
  return;
74302
74308
  }
74309
+ // Block zoom wheel when sphereBrush/sphereEraser is actively placing (left button held)
74310
+ if ((this.ctx.gui_states.mode.sphereBrush || this.ctx.gui_states.mode.sphereEraser)
74311
+ && ((_b = this.ctx.eventRouter) === null || _b === void 0 ? void 0 : _b.isLeftButtonDown())) {
74312
+ return;
74313
+ }
74303
74314
  e.preventDefault();
74304
74315
  const delta = e.detail ? e.detail > 0 : e.wheelDelta < 0;
74305
74316
  this.ctx.protectedData.isDrawing = true;
@@ -74901,6 +74912,493 @@ class ImageStoreHelper extends BaseTool {
74901
74912
  }
74902
74913
  }
74903
74914
 
74915
+ /**
74916
+ * SphereBrushTool — 3D sphere brush and eraser on layer MaskVolume.
74917
+ *
74918
+ * Unlike SphereTool (which uses its own isolated sphereMaskVolume),
74919
+ * this tool writes/erases directly in the active layer's shared MaskVolume —
74920
+ * the same volume used by pencil/brush/eraser tools.
74921
+ *
74922
+ * Two operation modes handled by one class:
74923
+ * - **sphereBrush**: Direct click to paint a 3D sphere of the active channel label.
74924
+ * - **sphereEraser**: Shift+click to erase a 3D sphere (only voxels matching active channel).
74925
+ *
74926
+ * Interaction:
74927
+ * 1. Pointer-down records sphere center, switches wheel to radius mode.
74928
+ * 2. Scroll wheel adjusts sphereBrushRadius [1, 50].
74929
+ * 3. Pointer-up writes/erases sphere to volume, captures grouped undo, refreshes canvas.
74930
+ */
74931
+ class SphereBrushTool extends BaseTool {
74932
+ constructor(ctx, callbacks) {
74933
+ super(ctx);
74934
+ /** Recorded sphere center in canvas mm-space */
74935
+ this.centerX = 0;
74936
+ this.centerY = 0;
74937
+ this.centerSlice = 0;
74938
+ /** Whether a sphere placement is in progress */
74939
+ this.active = false;
74940
+ /** Current operation mode for the active placement */
74941
+ this.mode = "brush";
74942
+ /** Cumulative "before" snapshots for drag-erase undo (z-index → slice data) */
74943
+ this.dragBeforeSnapshots = new Map();
74944
+ /** Whether a drag-erase has actually moved (vs. simple click-release) */
74945
+ this.dragMoved = false;
74946
+ this.callbacks = callbacks;
74947
+ }
74948
+ setCallbacks(callbacks) {
74949
+ this.callbacks = callbacks;
74950
+ }
74951
+ // ── Geometry (ported from SphereTool) ──────────────────────────
74952
+ /**
74953
+ * Convert canvas mm-space coordinates to 3D voxel coordinates.
74954
+ */
74955
+ canvasToVoxelCenter(canvasX, canvasY, sliceIndex, axis) {
74956
+ const nrrd = this.ctx.nrrd_states;
74957
+ switch (axis) {
74958
+ case 'z':
74959
+ return {
74960
+ x: canvasX * nrrd.image.nrrd_x_pixel / nrrd.image.nrrd_x_mm,
74961
+ y: canvasY * nrrd.image.nrrd_y_pixel / nrrd.image.nrrd_y_mm,
74962
+ z: sliceIndex,
74963
+ };
74964
+ case 'y':
74965
+ return {
74966
+ x: canvasX * nrrd.image.nrrd_x_pixel / nrrd.image.nrrd_x_mm,
74967
+ y: sliceIndex,
74968
+ z: (nrrd.image.nrrd_z_mm - canvasY) * nrrd.image.nrrd_z_pixel / nrrd.image.nrrd_z_mm,
74969
+ };
74970
+ case 'x':
74971
+ return {
74972
+ x: sliceIndex,
74973
+ y: canvasY * nrrd.image.nrrd_y_pixel / nrrd.image.nrrd_y_mm,
74974
+ z: canvasX * nrrd.image.nrrd_z_pixel / nrrd.image.nrrd_z_mm,
74975
+ };
74976
+ }
74977
+ }
74978
+ /**
74979
+ * Convert mm radius to per-axis voxel radii.
74980
+ */
74981
+ getVoxelRadii(radius) {
74982
+ const nrrd = this.ctx.nrrd_states;
74983
+ return {
74984
+ rx: radius * nrrd.image.nrrd_x_pixel / nrrd.image.nrrd_x_mm,
74985
+ ry: radius * nrrd.image.nrrd_y_pixel / nrrd.image.nrrd_y_mm,
74986
+ rz: radius * nrrd.image.nrrd_z_pixel / nrrd.image.nrrd_z_mm,
74987
+ };
74988
+ }
74989
+ /**
74990
+ * Compute clamped bounding box for an ellipsoid in voxel space.
74991
+ */
74992
+ getBoundingBox(center, radii, dims) {
74993
+ return {
74994
+ minX: Math.max(0, Math.floor(center.x - radii.rx)),
74995
+ maxX: Math.min(dims.width - 1, Math.ceil(center.x + radii.rx)),
74996
+ minY: Math.max(0, Math.floor(center.y - radii.ry)),
74997
+ maxY: Math.min(dims.height - 1, Math.ceil(center.y + radii.ry)),
74998
+ minZ: Math.max(0, Math.floor(center.z - radii.rz)),
74999
+ maxZ: Math.min(dims.depth - 1, Math.ceil(center.z + radii.rz)),
75000
+ };
75001
+ }
75002
+ // ── 3D Volume Write / Erase ────────────────────────────────────
75003
+ /**
75004
+ * Write a 3D sphere of the active channel label into the layer's MaskVolume.
75005
+ */
75006
+ write3DSphereToBrush(vol, center, radii, bb, label) {
75007
+ const { rx, ry, rz } = radii;
75008
+ for (let z = bb.minZ; z <= bb.maxZ; z++) {
75009
+ for (let y = bb.minY; y <= bb.maxY; y++) {
75010
+ for (let x = bb.minX; x <= bb.maxX; x++) {
75011
+ const dx = rx > 0 ? (x - center.x) / rx : 0;
75012
+ const dy = ry > 0 ? (y - center.y) / ry : 0;
75013
+ const dz = rz > 0 ? (z - center.z) / rz : 0;
75014
+ if (dx * dx + dy * dy + dz * dz <= 1.0) {
75015
+ vol.setVoxel(x, y, z, label);
75016
+ }
75017
+ }
75018
+ }
75019
+ }
75020
+ }
75021
+ /**
75022
+ * Erase a 3D sphere from the layer's MaskVolume.
75023
+ * Only clears voxels whose current value equals the active channel label.
75024
+ */
75025
+ erase3DSphereFromVolume(vol, center, radii, bb, label) {
75026
+ const { rx, ry, rz } = radii;
75027
+ for (let z = bb.minZ; z <= bb.maxZ; z++) {
75028
+ for (let y = bb.minY; y <= bb.maxY; y++) {
75029
+ for (let x = bb.minX; x <= bb.maxX; x++) {
75030
+ const dx = rx > 0 ? (x - center.x) / rx : 0;
75031
+ const dy = ry > 0 ? (y - center.y) / ry : 0;
75032
+ const dz = rz > 0 ? (z - center.z) / rz : 0;
75033
+ if (dx * dx + dy * dy + dz * dz <= 1.0) {
75034
+ if (vol.getVoxel(x, y, z) === label) {
75035
+ vol.setVoxel(x, y, z, 0);
75036
+ }
75037
+ }
75038
+ }
75039
+ }
75040
+ }
75041
+ }
75042
+ // ── Undo Capture ───────────────────────────────────────────────
75043
+ /**
75044
+ * Capture slice snapshots for all Z-slices in the bounding box.
75045
+ * Used before and after the sphere operation to build MaskDelta groups.
75046
+ *
75047
+ * We always capture along the Z axis since the 3D sphere spans Z slices.
75048
+ */
75049
+ captureSliceSnapshots(vol, minZ, maxZ) {
75050
+ const snapshots = new Map();
75051
+ for (let z = minZ; z <= maxZ; z++) {
75052
+ try {
75053
+ const { data } = vol.getSliceUint8(z, 'z');
75054
+ snapshots.set(z, data.slice());
75055
+ }
75056
+ catch (_a) {
75057
+ // slice out of bounds — skip
75058
+ }
75059
+ }
75060
+ return snapshots;
75061
+ }
75062
+ /**
75063
+ * Build MaskDelta group from before/after snapshots.
75064
+ */
75065
+ buildUndoGroup(layerId, before, after) {
75066
+ const deltas = [];
75067
+ for (const [sliceIndex, oldSlice] of before) {
75068
+ const newSlice = after.get(sliceIndex);
75069
+ if (!newSlice)
75070
+ continue;
75071
+ // Only include slices that actually changed
75072
+ let changed = false;
75073
+ for (let i = 0; i < oldSlice.length; i++) {
75074
+ if (oldSlice[i] !== newSlice[i]) {
75075
+ changed = true;
75076
+ break;
75077
+ }
75078
+ }
75079
+ if (changed) {
75080
+ deltas.push({ layerId, axis: 'z', sliceIndex, oldSlice, newSlice });
75081
+ }
75082
+ }
75083
+ return deltas;
75084
+ }
75085
+ // ── Preview Rendering ──────────────────────────────────────────
75086
+ /**
75087
+ * Draw a preview circle on the sphere canvas during sphere placement.
75088
+ */
75089
+ drawPreview(mouseX, mouseY, radius, isEraser) {
75090
+ const sphereCanvas = this.ctx.protectedData.canvases.drawingSphereCanvas;
75091
+ const sphereCtx = this.ctx.protectedData.ctxes.drawingSphereCtx;
75092
+ // Clear and resize sphere canvas
75093
+ sphereCanvas.width = this.ctx.protectedData.canvases.originCanvas.width;
75094
+ sphereCanvas.height = this.ctx.protectedData.canvases.originCanvas.height;
75095
+ sphereCtx.beginPath();
75096
+ sphereCtx.arc(mouseX, mouseY, radius, 0, 2 * Math.PI);
75097
+ if (isEraser) {
75098
+ // Eraser preview: dashed outline
75099
+ sphereCtx.strokeStyle = "#ff4444";
75100
+ sphereCtx.lineWidth = 2;
75101
+ sphereCtx.setLineDash([4, 4]);
75102
+ sphereCtx.stroke();
75103
+ sphereCtx.setLineDash([]);
75104
+ }
75105
+ else {
75106
+ // Brush preview: semi-transparent fill with active channel color
75107
+ const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
75108
+ const color = this.getActiveChannelColor(channel);
75109
+ sphereCtx.fillStyle = color;
75110
+ sphereCtx.globalAlpha = 0.5;
75111
+ sphereCtx.fill();
75112
+ sphereCtx.globalAlpha = 1.0;
75113
+ }
75114
+ sphereCtx.closePath();
75115
+ }
75116
+ /**
75117
+ * Clear the sphere preview canvas.
75118
+ */
75119
+ clearPreview() {
75120
+ const sphereCanvas = this.ctx.protectedData.canvases.drawingSphereCanvas;
75121
+ sphereCanvas.width = sphereCanvas.width;
75122
+ }
75123
+ /**
75124
+ * Get the hex color for the active channel from the layer's MaskVolume.
75125
+ */
75126
+ getActiveChannelColor(channel) {
75127
+ var _a;
75128
+ const layer = this.ctx.gui_states.layerChannel.layer;
75129
+ const volumes = this.ctx.protectedData.maskData.volumes;
75130
+ const vol = volumes[layer];
75131
+ if (vol) {
75132
+ const rgba = vol.getChannelColor(channel);
75133
+ const r = rgba.r.toString(16).padStart(2, '0');
75134
+ const g = rgba.g.toString(16).padStart(2, '0');
75135
+ const b = rgba.b.toString(16).padStart(2, '0');
75136
+ return `#${r}${g}${b}`;
75137
+ }
75138
+ return (_a = CHANNEL_HEX_COLORS[channel]) !== null && _a !== void 0 ? _a : "#ff0000";
75139
+ }
75140
+ // ── Wheel Handler ──────────────────────────────────────────────
75141
+ /**
75142
+ * Returns a wheel event handler for adjusting sphereBrushRadius.
75143
+ * Used in both sphereBrush and sphereEraser modes.
75144
+ */
75145
+ configSphereBrushWheel() {
75146
+ return (e) => {
75147
+ e.preventDefault();
75148
+ const sphere = this.ctx.nrrd_states.sphere;
75149
+ if (e.deltaY < 0) {
75150
+ sphere.sphereBrushRadius += 1;
75151
+ }
75152
+ else {
75153
+ sphere.sphereBrushRadius -= 1;
75154
+ }
75155
+ sphere.sphereBrushRadius = Math.max(1, Math.min(sphere.sphereBrushRadius, 50));
75156
+ // Redraw preview
75157
+ if (this.active) {
75158
+ this.drawPreview(this.centerX, this.centerY, sphere.sphereBrushRadius, this.mode === "eraser");
75159
+ }
75160
+ };
75161
+ }
75162
+ // ── Sphere Brush Click/PointerUp ───────────────────────────────
75163
+ /**
75164
+ * Handle pointer-down in sphereBrush mode (direct click, no Shift needed).
75165
+ */
75166
+ onSphereBrushClick(e) {
75167
+ this.centerX = e.offsetX / this.ctx.nrrd_states.view.sizeFactor;
75168
+ this.centerY = e.offsetY / this.ctx.nrrd_states.view.sizeFactor;
75169
+ this.centerSlice = this.ctx.nrrd_states.view.currentSliceIndex;
75170
+ this.active = true;
75171
+ this.mode = "brush";
75172
+ this.drawPreview(this.centerX, this.centerY, this.ctx.nrrd_states.sphere.sphereBrushRadius, false);
75173
+ }
75174
+ /**
75175
+ * Handle pointer-up in sphereBrush mode — write sphere to volume.
75176
+ */
75177
+ onSphereBrushPointerUp() {
75178
+ if (!this.active || this.mode !== "brush")
75179
+ return;
75180
+ this.active = false;
75181
+ const layer = this.ctx.gui_states.layerChannel.layer;
75182
+ const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
75183
+ const axis = this.ctx.protectedData.axis;
75184
+ const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
75185
+ let vol;
75186
+ try {
75187
+ vol = this.callbacks.getVolumeForLayer(layer);
75188
+ }
75189
+ catch (_a) {
75190
+ this.clearPreview();
75191
+ return;
75192
+ }
75193
+ const dims = vol.getDimensions();
75194
+ const center = this.canvasToVoxelCenter(this.centerX, this.centerY, this.centerSlice, axis);
75195
+ const radii = this.getVoxelRadii(radius);
75196
+ const bb = this.getBoundingBox(center, radii, dims);
75197
+ // Capture pre-write snapshots
75198
+ const before = this.captureSliceSnapshots(vol, bb.minZ, bb.maxZ);
75199
+ // Write sphere
75200
+ this.write3DSphereToBrush(vol, center, radii, bb, channel);
75201
+ // Capture post-write snapshots and push undo group
75202
+ const after = this.captureSliceSnapshots(vol, bb.minZ, bb.maxZ);
75203
+ const deltas = this.buildUndoGroup(layer, before, after);
75204
+ if (deltas.length > 0) {
75205
+ this.callbacks.pushUndoGroup(deltas);
75206
+ }
75207
+ // Refresh display and notify backend of ALL changed slices
75208
+ this.refreshDisplay(layer, deltas);
75209
+ this.clearPreview();
75210
+ }
75211
+ // ── Sphere Eraser Click/PointerUp ──────────────────────────────
75212
+ /**
75213
+ * Handle pointer-down in sphereEraser mode.
75214
+ * Initializes drag tracking for cumulative undo.
75215
+ */
75216
+ onSphereEraserClick(e) {
75217
+ this.centerX = e.offsetX / this.ctx.nrrd_states.view.sizeFactor;
75218
+ this.centerY = e.offsetY / this.ctx.nrrd_states.view.sizeFactor;
75219
+ this.centerSlice = this.ctx.nrrd_states.view.currentSliceIndex;
75220
+ this.active = true;
75221
+ this.mode = "eraser";
75222
+ this.dragMoved = false;
75223
+ this.dragBeforeSnapshots.clear();
75224
+ // Capture initial "before" snapshots for the first sphere position
75225
+ const layer = this.ctx.gui_states.layerChannel.layer;
75226
+ const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
75227
+ try {
75228
+ const vol = this.callbacks.getVolumeForLayer(layer);
75229
+ const dims = vol.getDimensions();
75230
+ const axis = this.ctx.protectedData.axis;
75231
+ const center = this.canvasToVoxelCenter(this.centerX, this.centerY, this.centerSlice, axis);
75232
+ const radii = this.getVoxelRadii(radius);
75233
+ const bb = this.getBoundingBox(center, radii, dims);
75234
+ this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
75235
+ }
75236
+ catch (_a) {
75237
+ // Volume not ready
75238
+ }
75239
+ this.drawPreview(this.centerX, this.centerY, radius, true);
75240
+ }
75241
+ /**
75242
+ * Handle pointer-move in sphereEraser mode — drag to continuously erase.
75243
+ */
75244
+ onSphereEraserMove(e) {
75245
+ if (!this.active || this.mode !== "eraser")
75246
+ return;
75247
+ this.dragMoved = true;
75248
+ const mouseX = e.offsetX / this.ctx.nrrd_states.view.sizeFactor;
75249
+ const mouseY = e.offsetY / this.ctx.nrrd_states.view.sizeFactor;
75250
+ const sliceIndex = this.ctx.nrrd_states.view.currentSliceIndex;
75251
+ const layer = this.ctx.gui_states.layerChannel.layer;
75252
+ const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
75253
+ const axis = this.ctx.protectedData.axis;
75254
+ const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
75255
+ let vol;
75256
+ try {
75257
+ vol = this.callbacks.getVolumeForLayer(layer);
75258
+ }
75259
+ catch (_a) {
75260
+ return;
75261
+ }
75262
+ const dims = vol.getDimensions();
75263
+ const center = this.canvasToVoxelCenter(mouseX, mouseY, sliceIndex, axis);
75264
+ const radii = this.getVoxelRadii(radius);
75265
+ const bb = this.getBoundingBox(center, radii, dims);
75266
+ // Expand "before" snapshots to cover any new Z slices
75267
+ this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
75268
+ // Erase sphere at current position
75269
+ this.erase3DSphereFromVolume(vol, center, radii, bb, channel);
75270
+ // Update preview position
75271
+ this.drawPreview(mouseX, mouseY, radius, true);
75272
+ // Refresh display so user sees the erase in real-time
75273
+ this.refreshDisplay(layer);
75274
+ }
75275
+ /**
75276
+ * Handle pointer-up in sphereEraser mode — finalize erase + push undo group.
75277
+ * Works for both click-release and drag-release.
75278
+ */
75279
+ onSphereEraserPointerUp() {
75280
+ if (!this.active || this.mode !== "eraser")
75281
+ return;
75282
+ this.active = false;
75283
+ const layer = this.ctx.gui_states.layerChannel.layer;
75284
+ const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
75285
+ const axis = this.ctx.protectedData.axis;
75286
+ const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
75287
+ let vol;
75288
+ try {
75289
+ vol = this.callbacks.getVolumeForLayer(layer);
75290
+ }
75291
+ catch (_a) {
75292
+ this.clearPreview();
75293
+ this.dragBeforeSnapshots.clear();
75294
+ return;
75295
+ }
75296
+ // If no drag occurred, erase at the click position (original click-release behavior)
75297
+ if (!this.dragMoved) {
75298
+ const dims = vol.getDimensions();
75299
+ const center = this.canvasToVoxelCenter(this.centerX, this.centerY, this.centerSlice, axis);
75300
+ const radii = this.getVoxelRadii(radius);
75301
+ const bb = this.getBoundingBox(center, radii, dims);
75302
+ this.erase3DSphereFromVolume(vol, center, radii, bb, channel);
75303
+ }
75304
+ // Build undo group from cumulative "before" snapshots vs current state
75305
+ const after = this.captureSliceSnapshots(vol, this.dragMinZ(), this.dragMaxZ());
75306
+ const deltas = this.buildUndoGroup(layer, this.dragBeforeSnapshots, after);
75307
+ if (deltas.length > 0) {
75308
+ this.callbacks.pushUndoGroup(deltas);
75309
+ }
75310
+ // Refresh display and notify backend of ALL changed slices
75311
+ this.refreshDisplay(layer, deltas);
75312
+ this.clearPreview();
75313
+ this.dragBeforeSnapshots.clear();
75314
+ }
75315
+ /**
75316
+ * Expand cumulative "before" snapshots to cover z range [minZ, maxZ].
75317
+ * Only captures slices not yet in the map (preserves original state).
75318
+ */
75319
+ expandDragBeforeSnapshots(vol, minZ, maxZ) {
75320
+ for (let z = minZ; z <= maxZ; z++) {
75321
+ if (!this.dragBeforeSnapshots.has(z)) {
75322
+ try {
75323
+ const { data } = vol.getSliceUint8(z, 'z');
75324
+ this.dragBeforeSnapshots.set(z, data.slice());
75325
+ }
75326
+ catch (_a) {
75327
+ // slice out of bounds
75328
+ }
75329
+ }
75330
+ }
75331
+ }
75332
+ /** Get minimum Z index from drag snapshots */
75333
+ dragMinZ() {
75334
+ let min = Infinity;
75335
+ for (const z of this.dragBeforeSnapshots.keys()) {
75336
+ if (z < min)
75337
+ min = z;
75338
+ }
75339
+ return min === Infinity ? 0 : min;
75340
+ }
75341
+ /** Get maximum Z index from drag snapshots */
75342
+ dragMaxZ() {
75343
+ let max = -Infinity;
75344
+ for (const z of this.dragBeforeSnapshots.keys()) {
75345
+ if (z > max)
75346
+ max = z;
75347
+ }
75348
+ return max === -Infinity ? 0 : max;
75349
+ }
75350
+ // ── Post-write Display Refresh ─────────────────────────────────
75351
+ /**
75352
+ * After writing/erasing the sphere in the volume, re-render the current
75353
+ * slice's layer canvas and composite all layers to master.
75354
+ *
75355
+ * @param changedDeltas - If provided, fire onMaskChanged for ALL changed
75356
+ * slices (not just the current view slice) so the backend receives the
75357
+ * full 3D sphere data for NII/GLTF export.
75358
+ */
75359
+ refreshDisplay(layerId, changedDeltas) {
75360
+ // Re-render layer canvas from volume for the current slice
75361
+ const target = this.ctx.protectedData.layerTargets.get(layerId);
75362
+ if (target) {
75363
+ const { ctx, canvas } = target;
75364
+ canvas.width = canvas.width; // clear
75365
+ const buffer = this.callbacks.getOrCreateSliceBuffer(this.ctx.protectedData.axis);
75366
+ if (buffer) {
75367
+ this.callbacks.renderSliceToCanvas(layerId, this.ctx.protectedData.axis, this.ctx.nrrd_states.view.currentSliceIndex, buffer, ctx, this.ctx.nrrd_states.view.changedWidth, this.ctx.nrrd_states.view.changedHeight);
75368
+ }
75369
+ }
75370
+ // Composite all layers to master canvas
75371
+ this.callbacks.compositeAllLayers();
75372
+ // Fire onMaskChanged for ALL changed slices (not just the current view slice)
75373
+ // This ensures the backend receives the full 3D sphere data for export.
75374
+ try {
75375
+ const vol = this.callbacks.getVolumeForLayer(layerId);
75376
+ const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
75377
+ if (changedDeltas && changedDeltas.length > 0) {
75378
+ // Report every changed Z slice
75379
+ for (const delta of changedDeltas) {
75380
+ const { data: sliceData, width, height } = vol.getSliceUint8(delta.sliceIndex, delta.axis);
75381
+ this.ctx.callbacks.onMaskChanged(sliceData, layerId, channel, delta.sliceIndex, delta.axis, width, height, false);
75382
+ }
75383
+ }
75384
+ else {
75385
+ // Fallback: report only the current slice
75386
+ const axis = this.ctx.protectedData.axis;
75387
+ const sliceIndex = this.ctx.nrrd_states.view.currentSliceIndex;
75388
+ const { data: sliceData, width, height } = vol.getSliceUint8(sliceIndex, axis);
75389
+ this.ctx.callbacks.onMaskChanged(sliceData, layerId, channel, sliceIndex, axis, width, height, false);
75390
+ }
75391
+ }
75392
+ catch (_a) {
75393
+ // Volume not ready
75394
+ }
75395
+ }
75396
+ /** Whether a sphere placement is currently in progress */
75397
+ get isActive() {
75398
+ return this.active;
75399
+ }
75400
+ }
75401
+
74904
75402
  /**
74905
75403
  * DrawToolCore — Tool orchestration and event routing.
74906
75404
  *
@@ -74921,6 +75419,7 @@ class DrawToolCore {
74921
75419
  handleOnDrawingBrushCricleMove: (ev) => { },
74922
75420
  handleMouseZoomSliceWheel: (e) => { },
74923
75421
  handleSphereWheel: (e) => { },
75422
+ handleSphereBrushWheel: (e) => { },
74924
75423
  };
74925
75424
  this.contrastEventPrameters = {
74926
75425
  move_x: 0,
@@ -75003,6 +75502,16 @@ class DrawToolCore {
75003
75502
  pushUndoDelta: (delta) => this.undoManager.push(delta),
75004
75503
  getEraserUrls: () => this.eraserUrls,
75005
75504
  });
75505
+ this.sphereBrushTool = new SphereBrushTool(toolCtx, {
75506
+ getVolumeForLayer: (layer) => this.renderer.getVolumeForLayer(layer),
75507
+ compositeAllLayers: () => this.renderer.compositeAllLayers(),
75508
+ pushUndoGroup: (deltas) => this.undoManager.pushGroup(deltas),
75509
+ renderSliceToCanvas: (layer, axis, sliceIndex, buffer, ctx, w, h) => this.renderer.renderSliceToCanvas(layer, axis, sliceIndex, buffer, ctx, w, h),
75510
+ getOrCreateSliceBuffer: (axis) => this.renderer.getOrCreateSliceBuffer(axis),
75511
+ setEmptyCanvasSize: (axis) => this.setEmptyCanvasSize(axis),
75512
+ reloadMasksFromVolume: () => this.reloadMasksFromVolume(),
75513
+ getEraserUrls: () => this.eraserUrls,
75514
+ });
75006
75515
  }
75007
75516
  initDrawToolCore() {
75008
75517
  // Initialize EventRouter for centralized event handling
@@ -75047,12 +75556,28 @@ class DrawToolCore {
75047
75556
  if ((ev.ctrlKey || ev.metaKey) && (ev.key === redoKey || (ev.shiftKey && ev.key === undoKeyUpper))) {
75048
75557
  this.redoLastPainting();
75049
75558
  }
75559
+ // Handle mouse wheel mode toggle (Ctrl+1 = Scroll:Zoom, Ctrl+2 = Scroll:Slice)
75560
+ if ((ev.ctrlKey || ev.metaKey) && ev.key === '1') {
75561
+ ev.preventDefault();
75562
+ this.state.keyboardSettings.mouseWheel = 'Scroll:Zoom';
75563
+ this.updateMouseWheelEvent();
75564
+ return;
75565
+ }
75566
+ if ((ev.ctrlKey || ev.metaKey) && ev.key === '2') {
75567
+ ev.preventDefault();
75568
+ this.state.keyboardSettings.mouseWheel = 'Scroll:Slice';
75569
+ this.updateMouseWheelEvent();
75570
+ return;
75571
+ }
75050
75572
  // Handle crosshair toggle (allowed in drawing tools AND sphere mode)
75051
75573
  if (ev.key === this.state.keyboardSettings.crosshair) {
75052
75574
  this.eventRouter.toggleCrosshair();
75053
75575
  }
75054
75576
  // Handle draw mode (Shift key) - EventRouter already tracks this
75055
- if (ev.key === this.state.keyboardSettings.draw && !this.state.gui_states.mode.sphere) {
75577
+ if (ev.key === this.state.keyboardSettings.draw
75578
+ && !this.state.gui_states.mode.sphere
75579
+ && !this.state.gui_states.mode.sphereBrush
75580
+ && !this.state.gui_states.mode.sphereEraser) {
75056
75581
  if (this.eventRouter.isCtrlHeld()) {
75057
75582
  return; // Ctrl takes priority
75058
75583
  }
@@ -75104,11 +75629,16 @@ class DrawToolCore {
75104
75629
  if (this.drawingTool.isActive || this.panTool.isActive) {
75105
75630
  this.drawingPrameters.handleOnDrawingMouseMove(e);
75106
75631
  }
75632
+ // Drag-erase: route move to sphereBrushTool when sphereEraser is active
75633
+ if (this.sphereBrushTool.isActive && this.state.gui_states.mode.sphereEraser) {
75634
+ this.sphereBrushTool.onSphereEraserMove(e);
75635
+ }
75107
75636
  });
75108
75637
  this.eventRouter.setPointerUpHandler((e) => {
75109
75638
  if (this.drawingTool.isActive || this.drawingTool.painting
75110
75639
  || this.panTool.isActive
75111
- || (this.state.gui_states.mode.sphere && this.eventRouter.getMode() !== 'crosshair')) {
75640
+ || (this.state.gui_states.mode.sphere && this.eventRouter.getMode() !== 'crosshair')
75641
+ || this.sphereBrushTool.isActive) {
75112
75642
  this.drawingPrameters.handleOnDrawingMouseUp(e);
75113
75643
  }
75114
75644
  });
@@ -75123,6 +75653,9 @@ class DrawToolCore {
75123
75653
  else if (this.activeWheelMode === 'sphere') {
75124
75654
  this.drawingPrameters.handleSphereWheel(e);
75125
75655
  }
75656
+ else if (this.activeWheelMode === 'sphereBrush') {
75657
+ this.drawingPrameters.handleSphereBrushWheel(e);
75658
+ }
75126
75659
  });
75127
75660
  // Bind all event listeners
75128
75661
  this.eventRouter.bindAll();
@@ -75186,6 +75719,8 @@ class DrawToolCore {
75186
75719
  }
75187
75720
  // sphere Wheel
75188
75721
  this.drawingPrameters.handleSphereWheel = this.configMouseSphereWheel();
75722
+ // sphere brush Wheel
75723
+ this.drawingPrameters.handleSphereBrushWheel = this.sphereBrushTool.configSphereBrushWheel();
75189
75724
  // brush circle move — delegated to DrawingTool
75190
75725
  this.drawingPrameters.handleOnDrawingBrushCricleMove =
75191
75726
  this.drawingTool.createBrushTrackingHandler();
@@ -75216,6 +75751,16 @@ class DrawToolCore {
75216
75751
  e.offsetY / this.state.nrrd_states.view.sizeFactor;
75217
75752
  this.enableCrosshair();
75218
75753
  }
75754
+ else if (this.state.gui_states.mode.sphereBrush && !this.eventRouter.isCrosshairEnabled()) {
75755
+ // SphereBrush: direct click (no Shift needed)
75756
+ this.activeWheelMode = 'sphereBrush';
75757
+ this.sphereBrushTool.onSphereBrushClick(e);
75758
+ }
75759
+ else if (this.state.gui_states.mode.sphereEraser && !this.eventRouter.isCrosshairEnabled()) {
75760
+ // SphereEraser: direct click (Shift handled at mode level)
75761
+ this.activeWheelMode = 'sphereBrush';
75762
+ this.sphereBrushTool.onSphereEraserClick(e);
75763
+ }
75219
75764
  else if (this.state.gui_states.mode.sphere && !this.eventRouter.isCrosshairEnabled()) {
75220
75765
  this.handleSphereClick(e);
75221
75766
  }
@@ -75237,6 +75782,16 @@ class DrawToolCore {
75237
75782
  this.drawingTool.onPointerUp(e);
75238
75783
  this.activeWheelMode = 'zoom';
75239
75784
  }
75785
+ else if (this.sphereBrushTool.isActive) {
75786
+ // SphereBrush or SphereEraser pointer-up
75787
+ if (this.state.gui_states.mode.sphereBrush) {
75788
+ this.sphereBrushTool.onSphereBrushPointerUp();
75789
+ }
75790
+ else if (this.state.gui_states.mode.sphereEraser) {
75791
+ this.sphereBrushTool.onSphereEraserPointerUp();
75792
+ }
75793
+ this.activeWheelMode = 'zoom';
75794
+ }
75240
75795
  else if (this.state.gui_states.mode.sphere &&
75241
75796
  !this.eventRouter.isCrosshairEnabled()) {
75242
75797
  this.sphereTool.onSpherePointerUp();
@@ -75281,7 +75836,9 @@ class DrawToolCore {
75281
75836
  }
75282
75837
  }
75283
75838
  this.state.protectedData.ctxes.drawingCtx.drawImage(this.state.protectedData.canvases.drawingCanvasLayerMaster, 0, 0);
75284
- if (this.state.gui_states.mode.sphere) {
75839
+ if (this.state.gui_states.mode.sphere
75840
+ || this.state.gui_states.mode.sphereBrush
75841
+ || this.state.gui_states.mode.sphereEraser) {
75285
75842
  this.state.protectedData.ctxes.drawingCtx.drawImage(this.state.protectedData.canvases.drawingSphereCanvas, 0, 0, this.state.nrrd_states.view.changedWidth, this.state.nrrd_states.view.changedHeight);
75286
75843
  }
75287
75844
  }
@@ -75468,24 +76025,26 @@ class DrawToolCore {
75468
76025
  * Undo the last drawing operation on the active layer.
75469
76026
  */
75470
76027
  undoLastPainting() {
75471
- const delta = this.undoManager.undo();
75472
- if (!delta)
75473
- return;
75474
- try {
75475
- const vol = this.renderer.getVolumeForLayer(delta.layerId);
75476
- vol.setSliceUint8(delta.sliceIndex, delta.oldSlice, delta.axis);
75477
- }
75478
- catch (_a) {
76028
+ const entry = this.undoManager.undo();
76029
+ if (!entry)
75479
76030
  return;
75480
- }
75481
76031
  this.state.protectedData.isDrawing = true;
75482
- if (delta.axis === this.state.protectedData.axis && delta.sliceIndex === this.state.nrrd_states.view.currentSliceIndex) {
75483
- this.applyUndoRedoToCanvas(delta.layerId);
75484
- }
75485
- if (!this.state.nrrd_states.flags.loadingMaskData) {
75486
- const { data: sliceData, width, height } = this.renderer.getVolumeForLayer(delta.layerId)
75487
- .getSliceUint8(delta.sliceIndex, delta.axis);
75488
- this.state.annotationCallbacks.onMaskChanged(sliceData, delta.layerId, this.state.gui_states.layerChannel.activeChannel || 1, delta.sliceIndex, delta.axis, width, height, false);
76032
+ for (const delta of entry) {
76033
+ try {
76034
+ const vol = this.renderer.getVolumeForLayer(delta.layerId);
76035
+ vol.setSliceUint8(delta.sliceIndex, delta.oldSlice, delta.axis);
76036
+ }
76037
+ catch (_a) {
76038
+ continue;
76039
+ }
76040
+ if (delta.axis === this.state.protectedData.axis && delta.sliceIndex === this.state.nrrd_states.view.currentSliceIndex) {
76041
+ this.applyUndoRedoToCanvas(delta.layerId);
76042
+ }
76043
+ if (!this.state.nrrd_states.flags.loadingMaskData) {
76044
+ const { data: sliceData, width, height } = this.renderer.getVolumeForLayer(delta.layerId)
76045
+ .getSliceUint8(delta.sliceIndex, delta.axis);
76046
+ this.state.annotationCallbacks.onMaskChanged(sliceData, delta.layerId, this.state.gui_states.layerChannel.activeChannel || 1, delta.sliceIndex, delta.axis, width, height, false);
76047
+ }
75489
76048
  }
75490
76049
  this.setIsDrawFalse(1000);
75491
76050
  }
@@ -75493,24 +76052,26 @@ class DrawToolCore {
75493
76052
  * Redo the last undone operation on the active layer.
75494
76053
  */
75495
76054
  redoLastPainting() {
75496
- const delta = this.undoManager.redo();
75497
- if (!delta)
76055
+ const entry = this.undoManager.redo();
76056
+ if (!entry)
75498
76057
  return;
75499
- try {
75500
- const vol = this.renderer.getVolumeForLayer(delta.layerId);
75501
- vol.setSliceUint8(delta.sliceIndex, delta.newSlice, delta.axis);
75502
- }
75503
- catch (_a) {
75504
- return;
75505
- }
75506
76058
  this.state.protectedData.isDrawing = true;
75507
- if (delta.axis === this.state.protectedData.axis && delta.sliceIndex === this.state.nrrd_states.view.currentSliceIndex) {
75508
- this.applyUndoRedoToCanvas(delta.layerId);
75509
- }
75510
- if (!this.state.nrrd_states.flags.loadingMaskData) {
75511
- const { data: sliceData, width, height } = this.renderer.getVolumeForLayer(delta.layerId)
75512
- .getSliceUint8(delta.sliceIndex, delta.axis);
75513
- this.state.annotationCallbacks.onMaskChanged(sliceData, delta.layerId, this.state.gui_states.layerChannel.activeChannel || 1, delta.sliceIndex, delta.axis, width, height, false);
76059
+ for (const delta of entry) {
76060
+ try {
76061
+ const vol = this.renderer.getVolumeForLayer(delta.layerId);
76062
+ vol.setSliceUint8(delta.sliceIndex, delta.newSlice, delta.axis);
76063
+ }
76064
+ catch (_a) {
76065
+ continue;
76066
+ }
76067
+ if (delta.axis === this.state.protectedData.axis && delta.sliceIndex === this.state.nrrd_states.view.currentSliceIndex) {
76068
+ this.applyUndoRedoToCanvas(delta.layerId);
76069
+ }
76070
+ if (!this.state.nrrd_states.flags.loadingMaskData) {
76071
+ const { data: sliceData, width, height } = this.renderer.getVolumeForLayer(delta.layerId)
76072
+ .getSliceUint8(delta.sliceIndex, delta.axis);
76073
+ this.state.annotationCallbacks.onMaskChanged(sliceData, delta.layerId, this.state.gui_states.layerChannel.activeChannel || 1, delta.sliceIndex, delta.axis, width, height, false);
76074
+ }
75514
76075
  }
75515
76076
  this.setIsDrawFalse(1000);
75516
76077
  }
@@ -75584,6 +76145,14 @@ class DrawToolCore {
75584
76145
  exitSphereMode() {
75585
76146
  throw new Error("exitSphereMode must be provided by NrrdTools");
75586
76147
  }
76148
+ /** Override in NrrdTools */
76149
+ reloadMasksFromVolume() {
76150
+ throw new Error("reloadMasksFromVolume must be provided by NrrdTools");
76151
+ }
76152
+ /** Override in NrrdTools */
76153
+ updateMouseWheelEvent() {
76154
+ throw new Error("updateMouseWheelEvent must be provided by NrrdTools");
76155
+ }
75587
76156
  }
75588
76157
 
75589
76158
  /**
@@ -75647,6 +76216,7 @@ class NrrdState {
75647
76216
  nippleSphereOrigin: null,
75648
76217
  sphereMaskVolume: null,
75649
76218
  sphereRadius: 5,
76219
+ sphereBrushRadius: 10,
75650
76220
  };
75651
76221
  this.flags = {
75652
76222
  stepClear: 1,
@@ -75668,6 +76238,7 @@ class NrrdState {
75668
76238
  this.sphere.nippleSphereOrigin = null;
75669
76239
  this.sphere.sphereMaskVolume = null;
75670
76240
  this.sphere.sphereRadius = 5;
76241
+ this.sphere.sphereBrushRadius = 10;
75671
76242
  }
75672
76243
  }
75673
76244
 
@@ -75689,6 +76260,8 @@ class GuiState {
75689
76260
  pencil: true,
75690
76261
  eraser: false,
75691
76262
  sphere: false,
76263
+ sphereBrush: false,
76264
+ sphereEraser: false,
75692
76265
  activeSphereType: "tumour",
75693
76266
  };
75694
76267
  this.drawing = {
@@ -75883,6 +76456,216 @@ class CanvasState {
75883
76456
  }
75884
76457
  }
75885
76458
 
76459
+ /**
76460
+ * GaussianSmoother — 3D Gaussian smoothing for segmentation masks.
76461
+ *
76462
+ * Applies a separable 3D Gaussian blur to a single label channel within a
76463
+ * MaskVolume, then thresholds the result back to a binary mask. This smooths
76464
+ * jagged edges and fills small holes in segmentation annotations.
76465
+ *
76466
+ * Pure, stateless utility — no DOM/Canvas/GUI dependencies.
76467
+ *
76468
+ * Performance: uses direct typed-array access (bypassing getVoxel/setVoxel
76469
+ * boundary checks) and branch-free convolution for the interior region.
76470
+ */
76471
+ class GaussianSmoother {
76472
+ /**
76473
+ * Smooth a single label channel in-place using separable 3D Gaussian blur.
76474
+ *
76475
+ * Steps:
76476
+ * 1. Extract — create a Float32Array with 1.0 where voxel === channel, 0.0 elsewhere.
76477
+ * 2. Blur — apply separable Gaussian (X → Y → Z) on the float buffer.
76478
+ * 3. Threshold — binarize at 0.5.
76479
+ * 4. Write back — overwrite/erase voxels according to the thresholded result.
76480
+ *
76481
+ * @param volume The MaskVolume to operate on (modified in-place).
76482
+ * @param channel The label value to smooth (must be > 0).
76483
+ * @param sigma Base Gaussian sigma in voxel units (default 1.0).
76484
+ * @param spacing Optional voxel spacing [sx, sy, sz]. When provided, per-axis
76485
+ * sigma is computed as `sigma / spacing[axis]` so that the
76486
+ * physical smoothing radius is isotropic.
76487
+ */
76488
+ static gaussianSmooth3D(volume, channel, sigma = 1.0, spacing) {
76489
+ const dims = volume.getDimensions();
76490
+ const { width, height, depth } = dims;
76491
+ const totalVoxels = width * height * depth;
76492
+ // Direct access to underlying buffer — bypasses getVoxel/setVoxel
76493
+ // boundary checks for ~10x speedup in hot loops.
76494
+ const rawData = volume.getRawData();
76495
+ const channels = volume.getChannels();
76496
+ const bytesPerSlice = volume.getBytesPerSlice();
76497
+ const rowStride = width * channels;
76498
+ // Compute per-axis sigma (normalized to voxel units)
76499
+ const sigmaX = spacing ? sigma / spacing[0] : sigma;
76500
+ const sigmaY = spacing ? sigma / spacing[1] : sigma;
76501
+ const sigmaZ = spacing ? sigma / spacing[2] : sigma;
76502
+ // 1. Extract binary float buffer — direct array access
76503
+ const buffer = new Float32Array(totalVoxels);
76504
+ for (let z = 0; z < depth; z++) {
76505
+ const zOffset = z * bytesPerSlice;
76506
+ for (let y = 0; y < height; y++) {
76507
+ const zyOffset = zOffset + y * rowStride;
76508
+ for (let x = 0; x < width; x++) {
76509
+ const bufIdx = z * height * width + y * width + x;
76510
+ buffer[bufIdx] = rawData[zyOffset + x * channels] === channel ? 1.0 : 0.0;
76511
+ }
76512
+ }
76513
+ }
76514
+ // 2. Separable Gaussian blur: X → Y → Z
76515
+ const kernelX = GaussianSmoother.generateKernel1D(sigmaX);
76516
+ const kernelY = GaussianSmoother.generateKernel1D(sigmaY);
76517
+ const kernelZ = GaussianSmoother.generateKernel1D(sigmaZ);
76518
+ GaussianSmoother.convolve1D(buffer, width, height, depth, 0, kernelX); // X axis
76519
+ GaussianSmoother.convolve1D(buffer, width, height, depth, 1, kernelY); // Y axis
76520
+ GaussianSmoother.convolve1D(buffer, width, height, depth, 2, kernelZ); // Z axis
76521
+ // 3 & 4. Threshold and write back — direct array access
76522
+ for (let z = 0; z < depth; z++) {
76523
+ const zOffset = z * bytesPerSlice;
76524
+ for (let y = 0; y < height; y++) {
76525
+ const zyOffset = zOffset + y * rowStride;
76526
+ for (let x = 0; x < width; x++) {
76527
+ const bufIdx = z * height * width + y * width + x;
76528
+ const smoothed = buffer[bufIdx] >= 0.5 ? 1 : 0;
76529
+ const rawIdx = zyOffset + x * channels;
76530
+ const original = rawData[rawIdx];
76531
+ if (smoothed === 1 && original !== channel) {
76532
+ // Smoothed region expands into this voxel — overwrite
76533
+ rawData[rawIdx] = channel;
76534
+ }
76535
+ else if (smoothed === 0 && original === channel) {
76536
+ // Smoothed region retreats — erase
76537
+ rawData[rawIdx] = 0;
76538
+ }
76539
+ // Otherwise leave unchanged
76540
+ }
76541
+ }
76542
+ }
76543
+ }
76544
+ /**
76545
+ * Generate a normalized 1D Gaussian kernel truncated at ±3σ.
76546
+ *
76547
+ * @param sigma Standard deviation (in voxels). Must be > 0.
76548
+ * @returns Normalized Float32Array of odd length.
76549
+ */
76550
+ static generateKernel1D(sigma) {
76551
+ if (sigma <= 0) {
76552
+ return new Float32Array([1.0]);
76553
+ }
76554
+ const radius = Math.ceil(sigma * 3);
76555
+ const size = 2 * radius + 1;
76556
+ const kernel = new Float32Array(size);
76557
+ const twoSigmaSq = 2 * sigma * sigma;
76558
+ let sum = 0;
76559
+ for (let i = 0; i < size; i++) {
76560
+ const x = i - radius;
76561
+ kernel[i] = Math.exp(-(x * x) / twoSigmaSq);
76562
+ sum += kernel[i];
76563
+ }
76564
+ // Normalize
76565
+ for (let i = 0; i < size; i++) {
76566
+ kernel[i] /= sum;
76567
+ }
76568
+ return kernel;
76569
+ }
76570
+ /**
76571
+ * In-place single-axis convolution with zero-padding at boundaries.
76572
+ *
76573
+ * Uses a 3-segment approach: left boundary (with bounds check),
76574
+ * middle interior (branch-free, ~95% of work), right boundary
76575
+ * (with bounds check). This eliminates per-element branching
76576
+ * in the hot inner loop.
76577
+ *
76578
+ * @param data Flat float buffer (width × height × depth).
76579
+ * @param width X dimension.
76580
+ * @param height Y dimension.
76581
+ * @param depth Z dimension.
76582
+ * @param axis 0 = X, 1 = Y, 2 = Z.
76583
+ * @param kernel 1D convolution kernel (odd length).
76584
+ */
76585
+ static convolve1D(data, width, height, depth, axis, kernel) {
76586
+ const kLen = kernel.length;
76587
+ const radius = (kLen - 1) / 2;
76588
+ // Determine the dimension length along the convolution axis
76589
+ // and the stride between consecutive elements along that axis
76590
+ let axisLen;
76591
+ let stride;
76592
+ let outerCount;
76593
+ if (axis === 0) {
76594
+ // X axis: stride = 1, iterate over all (y, z) lines
76595
+ axisLen = width;
76596
+ stride = 1;
76597
+ outerCount = height * depth;
76598
+ }
76599
+ else if (axis === 1) {
76600
+ // Y axis: stride = width, iterate over all (x, z) lines
76601
+ axisLen = height;
76602
+ stride = width;
76603
+ outerCount = width * depth;
76604
+ }
76605
+ else {
76606
+ // Z axis: stride = width * height, iterate over all (x, y) lines
76607
+ axisLen = depth;
76608
+ stride = width * height;
76609
+ outerCount = width * height;
76610
+ }
76611
+ // Precompute middle segment bounds
76612
+ const midStart = radius;
76613
+ const midEnd = axisLen - radius;
76614
+ // Temporary line buffer to avoid read-after-write issues
76615
+ const line = new Float32Array(axisLen);
76616
+ for (let outer = 0; outer < outerCount; outer++) {
76617
+ // Compute the starting index for this line
76618
+ let lineStart;
76619
+ if (axis === 0) {
76620
+ lineStart = outer * width;
76621
+ }
76622
+ else if (axis === 1) {
76623
+ const z = Math.floor(outer / width);
76624
+ const x = outer % width;
76625
+ lineStart = z * width * height + x;
76626
+ }
76627
+ else {
76628
+ lineStart = outer;
76629
+ }
76630
+ // Read the line
76631
+ for (let i = 0; i < axisLen; i++) {
76632
+ line[i] = data[lineStart + i * stride];
76633
+ }
76634
+ // Left boundary segment (i = 0 .. radius-1): lower bound check
76635
+ for (let i = 0; i < midStart; i++) {
76636
+ let sum = 0;
76637
+ for (let k = 0; k < kLen; k++) {
76638
+ const j = i + k - radius;
76639
+ if (j >= 0) {
76640
+ sum += line[j] * kernel[k];
76641
+ }
76642
+ }
76643
+ data[lineStart + i * stride] = sum;
76644
+ }
76645
+ // Middle segment (i = radius .. axisLen-radius-1): NO bounds check
76646
+ for (let i = midStart; i < midEnd; i++) {
76647
+ let sum = 0;
76648
+ const lineOffset = i - radius;
76649
+ for (let k = 0; k < kLen; k++) {
76650
+ sum += line[lineOffset + k] * kernel[k];
76651
+ }
76652
+ data[lineStart + i * stride] = sum;
76653
+ }
76654
+ // Right boundary segment (i = axisLen-radius .. axisLen-1): upper bound check
76655
+ for (let i = midEnd; i < axisLen; i++) {
76656
+ let sum = 0;
76657
+ for (let k = 0; k < kLen; k++) {
76658
+ const j = i + k - radius;
76659
+ if (j < axisLen) {
76660
+ sum += line[j] * kernel[k];
76661
+ }
76662
+ }
76663
+ data[lineStart + i * stride] = sum;
76664
+ }
76665
+ }
76666
+ }
76667
+ }
76668
+
75886
76669
  /**
75887
76670
  * Manages layer/channel state: active selection, visibility, and channel colors.
75888
76671
  *
@@ -76399,6 +77182,8 @@ class SliceRenderPipeline extends BaseTool {
76399
77182
  * Then update the changedWidth and changedHeight based on the sizeFactor.
76400
77183
  */
76401
77184
  updateOriginAndChangedWH() {
77185
+ const prevW = this.ctx.nrrd_states.image.originWidth;
77186
+ const prevH = this.ctx.nrrd_states.image.originHeight;
76402
77187
  this.ctx.nrrd_states.image.originWidth =
76403
77188
  this.ctx.protectedData.canvases.originCanvas.width;
76404
77189
  this.ctx.nrrd_states.image.originHeight =
@@ -76407,7 +77192,12 @@ class SliceRenderPipeline extends BaseTool {
76407
77192
  // Setting them here would defeat the sizeChanged detection in resizePaintArea,
76408
77193
  // causing canvas elements to keep stale dimensions after axis switches.
76409
77194
  this.resizePaintArea(this.ctx.nrrd_states.view.sizeFactor);
76410
- this.resetPaintAreaUIPosition();
77195
+ // Only re-center when origin dimensions changed (e.g. axis switch).
77196
+ // Contrast toggle doesn't change origin size, so skip to preserve user's pan/zoom.
77197
+ if (prevW !== this.ctx.nrrd_states.image.originWidth ||
77198
+ prevH !== this.ctx.nrrd_states.image.originHeight) {
77199
+ this.resetPaintAreaUIPosition();
77200
+ }
76411
77201
  }
76412
77202
  /**
76413
77203
  * Keep all contrast slice index to same.
@@ -76621,7 +77411,7 @@ class NrrdTools {
76621
77411
  // Wire RenderingUtils' setEmptyCanvasSize callback
76622
77412
  this.drawCore.renderer.setEmptyCanvasSize = (axis) => this.setEmptyCanvasSize(axis);
76623
77413
  this.init();
76624
- this.dragOperator = new DragOperator(this.container, this.state.nrrd_states, this.state.gui_states, this.state.protectedData, this.drawCore.drawingPrameters, this.setSyncsliceNum.bind(this), this.setIsDrawFalse.bind(this), this.flipDisplayImageByAxis.bind(this), this.setEmptyCanvasSize.bind(this), this.drawCore.renderer.getOrCreateSliceBuffer.bind(this.drawCore.renderer), this.drawCore.renderer.renderSliceToCanvas.bind(this.drawCore.renderer));
77414
+ this.dragOperator = new DragOperator(this.container, this.state.nrrd_states, this.state.gui_states, this.state.protectedData, this.drawCore.drawingPrameters, this.setSyncsliceNum.bind(this), this.setIsDrawFalse.bind(this), this.flipDisplayImageByAxis.bind(this), this.setEmptyCanvasSize.bind(this), this.drawCore.renderer.getOrCreateSliceBuffer.bind(this.drawCore.renderer), this.drawCore.renderer.renderSliceToCanvas.bind(this.drawCore.renderer), this.drawCore.renderer.compositeAllLayers.bind(this.drawCore.renderer));
76625
77415
  // Inject EventRouter into DragOperator for centralized event handling
76626
77416
  if (this.drawCore.eventRouter) {
76627
77417
  this.dragOperator.setEventRouter(this.drawCore.eventRouter);
@@ -76648,6 +77438,8 @@ class NrrdTools {
76648
77438
  this.drawCore.enterSphereMode = () => this.enterSphereMode();
76649
77439
  this.drawCore.exitSphereMode = () => this.exitSphereMode();
76650
77440
  this.drawCore.configMouseSliceWheel = () => this.configMouseSliceWheel();
77441
+ this.drawCore.reloadMasksFromVolume = () => this.reloadMasksFromVolume();
77442
+ this.drawCore.updateMouseWheelEvent = () => this.updateMouseWheelEvent();
76651
77443
  }
76652
77444
  /**
76653
77445
  * A initialise function for nrrd_tools
@@ -76768,13 +77560,18 @@ class NrrdTools {
76768
77560
  * Set the current tool mode.
76769
77561
  */
76770
77562
  setMode(mode) {
77563
+ var _a, _b, _c;
76771
77564
  if (!this.guiCallbacks)
76772
77565
  return;
76773
77566
  const prevSphere = this.state.gui_states.mode.sphere;
77567
+ const prevSphereBrush = this.state.gui_states.mode.sphereBrush;
77568
+ const prevSphereEraser = this.state.gui_states.mode.sphereEraser;
76774
77569
  const prevCalculator = this._calculatorActive;
76775
77570
  this.state.gui_states.mode.pencil = false;
76776
77571
  this.state.gui_states.mode.eraser = false;
76777
77572
  this.state.gui_states.mode.sphere = false;
77573
+ this.state.gui_states.mode.sphereBrush = false;
77574
+ this.state.gui_states.mode.sphereEraser = false;
76778
77575
  this._calculatorActive = false;
76779
77576
  switch (mode) {
76780
77577
  case "pencil":
@@ -76795,6 +77592,24 @@ class NrrdTools {
76795
77592
  this.state.gui_states.mode.sphere = true;
76796
77593
  this._calculatorActive = true;
76797
77594
  break;
77595
+ case "sphereBrush":
77596
+ this.state.gui_states.mode.sphereBrush = true;
77597
+ this.dragOperator.removeDragMode();
77598
+ (_a = this.drawCore.eventRouter) === null || _a === void 0 ? void 0 : _a.setGuiTool('sphereBrush');
77599
+ break;
77600
+ case "sphereEraser":
77601
+ this.state.gui_states.mode.sphereEraser = true;
77602
+ this.dragOperator.removeDragMode();
77603
+ (_b = this.drawCore.eventRouter) === null || _b === void 0 ? void 0 : _b.setGuiTool('sphereEraser');
77604
+ break;
77605
+ }
77606
+ // Restore drag mode when leaving sphereBrush/sphereEraser
77607
+ if ((prevSphereBrush || prevSphereEraser)
77608
+ && !this.state.gui_states.mode.sphereBrush
77609
+ && !this.state.gui_states.mode.sphereEraser
77610
+ && !this.state.gui_states.mode.sphere) {
77611
+ this.dragOperator.configDragMode();
77612
+ (_c = this.drawCore.eventRouter) === null || _c === void 0 ? void 0 : _c.setGuiTool('pencil');
76798
77613
  }
76799
77614
  if (prevSphere && !this.state.gui_states.mode.sphere) {
76800
77615
  this.guiCallbacks.updateSphereState();
@@ -76807,6 +77622,10 @@ class NrrdTools {
76807
77622
  getMode() {
76808
77623
  if (this._calculatorActive)
76809
77624
  return "calculator";
77625
+ if (this.state.gui_states.mode.sphereBrush)
77626
+ return "sphereBrush";
77627
+ if (this.state.gui_states.mode.sphereEraser)
77628
+ return "sphereEraser";
76810
77629
  if (this.state.gui_states.mode.sphere)
76811
77630
  return "sphere";
76812
77631
  if (this.state.gui_states.mode.eraser)
@@ -76832,6 +77651,12 @@ class NrrdTools {
76832
77651
  getBrushSize() {
76833
77652
  return this.state.gui_states.drawing.brushAndEraserSize;
76834
77653
  }
77654
+ setSphereBrushRadius(radius) {
77655
+ this.state.nrrd_states.sphere.sphereBrushRadius = Math.max(1, Math.min(50, radius));
77656
+ }
77657
+ getSphereBrushRadius() {
77658
+ return this.state.nrrd_states.sphere.sphereBrushRadius;
77659
+ }
76835
77660
  setWindowHigh(value) {
76836
77661
  var _a;
76837
77662
  this.state.gui_states.viewConfig.readyToUpdate = false;
@@ -76905,7 +77730,8 @@ class NrrdTools {
76905
77730
  getPencilColor() {
76906
77731
  return this.state.gui_states.drawing.color;
76907
77732
  }
76908
- executeAction(action) {
77733
+ executeAction(action, opts) {
77734
+ var _a;
76909
77735
  switch (action) {
76910
77736
  case "undo":
76911
77737
  this.undo();
@@ -76943,6 +77769,48 @@ class NrrdTools {
76943
77769
  enableDownload(config);
76944
77770
  break;
76945
77771
  }
77772
+ case "gaussianSmooth": {
77773
+ const layerId = this.state.gui_states.layerChannel.layer;
77774
+ const channel = this.state.gui_states.layerChannel.activeChannel;
77775
+ const volume = this.drawCore.renderer.getVolumeForLayer(layerId);
77776
+ if (volume && channel > 0) {
77777
+ const dims = volume.getDimensions();
77778
+ // 1. Snapshot all Z-slices before mutation (undo support)
77779
+ const deltas = [];
77780
+ for (let z = 0; z < dims.depth; z++) {
77781
+ const sliceData = volume.getSliceUint8(z, "z").data;
77782
+ const hasChannel = sliceData.some((v) => v === channel);
77783
+ if (hasChannel) {
77784
+ deltas.push({
77785
+ layerId,
77786
+ axis: "z",
77787
+ sliceIndex: z,
77788
+ oldSlice: sliceData.slice(),
77789
+ newSlice: new Uint8Array(0), // placeholder, filled after smoothing
77790
+ });
77791
+ }
77792
+ }
77793
+ // 2. Apply smoothing with anisotropic spacing
77794
+ const spacing = this.state.nrrd_states.image.voxelSpacing;
77795
+ const spacingTuple = spacing.length >= 3 ? [spacing[0], spacing[1], spacing[2]] : undefined;
77796
+ const sigma = (_a = opts === null || opts === void 0 ? void 0 : opts.sigma) !== null && _a !== void 0 ? _a : 1.0;
77797
+ GaussianSmoother.gaussianSmooth3D(volume, channel, sigma, spacingTuple);
77798
+ // 3. Capture newSlice data after smoothing and push undo group
77799
+ for (const delta of deltas) {
77800
+ delta.newSlice = volume.getSliceUint8(delta.sliceIndex, "z").data.slice();
77801
+ }
77802
+ if (deltas.length > 0) {
77803
+ this.drawCore.undoManager.pushGroup(deltas);
77804
+ }
77805
+ // 4. Notify backend for each changed slice
77806
+ for (const delta of deltas) {
77807
+ const { data: sliceData, width, height } = volume.getSliceUint8(delta.sliceIndex, "z");
77808
+ this.state.annotationCallbacks.onMaskChanged(sliceData, layerId, channel, delta.sliceIndex, "z", width, height, false);
77809
+ }
77810
+ this.reloadMasksFromVolume();
77811
+ }
77812
+ break;
77813
+ }
76946
77814
  }
76947
77815
  }
76948
77816
  // ═══════════════════════════════════════════════════════════════════════════
@@ -77381,10 +78249,15 @@ class NrrdTools {
77381
78249
  // ═══════════════════════════════════════════════════════════════════════════
77382
78250
  configMouseSliceWheel() {
77383
78251
  const handleMouseZoomSliceWheelMove = (e) => {
77384
- var _a;
78252
+ var _a, _b;
77385
78253
  if ((_a = this.drawCore.eventRouter) === null || _a === void 0 ? void 0 : _a.isShiftHeld()) {
77386
78254
  return;
77387
78255
  }
78256
+ // Block slice wheel when sphereBrush/sphereEraser is actively placing (left button held)
78257
+ if ((this.state.gui_states.mode.sphereBrush || this.state.gui_states.mode.sphereEraser)
78258
+ && ((_b = this.drawCore.eventRouter) === null || _b === void 0 ? void 0 : _b.isLeftButtonDown())) {
78259
+ return;
78260
+ }
77388
78261
  e.preventDefault();
77389
78262
  if (e.deltaY < 0) {
77390
78263
  this.setSliceMoving(-1);
@@ -77652,7 +78525,7 @@ function evaluateElement(element, weights) {
77652
78525
  }
77653
78526
 
77654
78527
  // import * as kiwrious from "copper3d_plugin_heart_k";
77655
- const REVISION = "v3.1.2-beta";
78528
+ const REVISION = "v3.2.1-beta";
77656
78529
  console.log(`%cCopper3D Visualisation %cBeta:${REVISION}`, "padding: 3px;color:white; background:#023047", "padding: 3px;color:white; background:#f50a25");
77657
78530
 
77658
- export { CHANNEL_COLORS, CHANNEL_HEX_COLORS, CameraViewPoint, Copper3dTrackballControls, MeshNodeTool, NrrdTools, REVISION, addBoxHelper, addLabelToScene, configKiwriousHeart, convert3DPostoScreenPos, convertScreenPosto3DPos, copperMScene, copperMSceneRenderer, copperRenderer, copperRendererOnDemond, copperScene, copperSceneOnDemond, createTexture2D_NRRD, fullScreenListenner, kiwrious, loading, removeGuiFolderChilden, rgbaToCss, rgbaToHex, setHDRFilePath, throttle };
78531
+ export { CHANNEL_COLORS, CHANNEL_HEX_COLORS, CameraViewPoint, Copper3dTrackballControls, GaussianSmoother, MeshNodeTool, NrrdTools, REVISION, addBoxHelper, addLabelToScene, configKiwriousHeart, convert3DPostoScreenPos, convertScreenPosto3DPos, copperMScene, copperMSceneRenderer, copperRenderer, copperRendererOnDemond, copperScene, copperSceneOnDemond, createTexture2D_NRRD, fullScreenListenner, kiwrious, loading, removeGuiFolderChilden, rgbaToCss, rgbaToHex, setHDRFilePath, throttle };