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
@@ -71395,6 +71395,17 @@ void main() {
71395
71395
  getChannels() {
71396
71396
  return this.numChannels;
71397
71397
  }
71398
+ /**
71399
+ * Return the number of bytes in one complete z-slice.
71400
+ *
71401
+ * Equal to `width × height × channels`. Useful for direct buffer
71402
+ * index arithmetic in performance-critical code paths.
71403
+ *
71404
+ * @returns Bytes per z-slice.
71405
+ */
71406
+ getBytesPerSlice() {
71407
+ return this.bytesPerSlice;
71408
+ }
71398
71409
  /**
71399
71410
  * Return total memory used by the backing buffer, in bytes.
71400
71411
  *
@@ -71963,41 +71974,48 @@ void main() {
71963
71974
  setActiveLayer(layer) {
71964
71975
  this.activeLayer = layer;
71965
71976
  }
71966
- /** Push a delta onto the active layer's undo stack and clear the redo stack. */
71977
+ /** Push a single delta onto the active layer's undo stack and clear the redo stack. */
71967
71978
  push(delta) {
71979
+ this.pushGroup([delta]);
71980
+ }
71981
+ /** Push a group of deltas as a single undo unit (e.g. 3D sphere spanning multiple slices). */
71982
+ pushGroup(deltas) {
71968
71983
  var _a, _b;
71969
- const stack = (_a = this.undoStacks.get(delta.layerId)) !== null && _a !== void 0 ? _a : this.undoStacks.get("layer1");
71970
- stack.push(delta);
71984
+ if (deltas.length === 0)
71985
+ return;
71986
+ const layerId = deltas[0].layerId;
71987
+ const stack = (_a = this.undoStacks.get(layerId)) !== null && _a !== void 0 ? _a : this.undoStacks.get("layer1");
71988
+ stack.push(deltas);
71971
71989
  if (stack.length > MAX_STACK_SIZE) {
71972
71990
  stack.shift();
71973
71991
  }
71974
71992
  // Any new operation invalidates the redo history for that layer
71975
- const redoStack = (_b = this.redoStacks.get(delta.layerId)) !== null && _b !== void 0 ? _b : this.redoStacks.get("layer1");
71993
+ const redoStack = (_b = this.redoStacks.get(layerId)) !== null && _b !== void 0 ? _b : this.redoStacks.get("layer1");
71976
71994
  redoStack.length = 0;
71977
71995
  }
71978
71996
  /**
71979
71997
  * Undo the last operation on the active layer.
71980
- * @returns The delta that was undone, or undefined if nothing to undo.
71998
+ * @returns The delta(s) that were undone, or undefined if nothing to undo.
71981
71999
  */
71982
72000
  undo() {
71983
72001
  const stack = this.undoStacks.get(this.activeLayer);
71984
- const delta = stack.pop();
71985
- if (delta) {
71986
- this.redoStacks.get(this.activeLayer).push(delta);
72002
+ const entry = stack.pop();
72003
+ if (entry) {
72004
+ this.redoStacks.get(this.activeLayer).push(entry);
71987
72005
  }
71988
- return delta;
72006
+ return entry;
71989
72007
  }
71990
72008
  /**
71991
72009
  * Redo the last undone operation on the active layer.
71992
- * @returns The delta that was redone, or undefined if nothing to redo.
72010
+ * @returns The delta(s) that were redone, or undefined if nothing to redo.
71993
72011
  */
71994
72012
  redo() {
71995
72013
  const stack = this.redoStacks.get(this.activeLayer);
71996
- const delta = stack.pop();
71997
- if (delta) {
71998
- this.undoStacks.get(this.activeLayer).push(delta);
72014
+ const entry = stack.pop();
72015
+ if (entry) {
72016
+ this.undoStacks.get(this.activeLayer).push(entry);
71999
72017
  }
72000
- return delta;
72018
+ return entry;
72001
72019
  }
72002
72020
  canUndo() {
72003
72021
  var _a, _b;
@@ -73205,8 +73223,8 @@ void main() {
73205
73223
  }
73206
73224
  }
73207
73225
  }
73208
- // Composite all layers to master canvas
73209
- this.compositeAllLayers();
73226
+ // Composite all layers to master canvas (uses per-layer alpha)
73227
+ this.callbacks.compositeAllLayers();
73210
73228
  }
73211
73229
  // Refresh sphere overlay from volume for the new slice
73212
73230
  if (this.ctx.gui_states.mode.sphere) {
@@ -73215,23 +73233,6 @@ void main() {
73215
73233
  view.switchSliceFlag = false;
73216
73234
  }
73217
73235
  }
73218
- /**
73219
- * Composite all visible layer canvases to the master display canvas.
73220
- */
73221
- compositeAllLayers() {
73222
- const masterCtx = this.ctx.protectedData.ctxes.drawingLayerMasterCtx;
73223
- const width = this.ctx.nrrd_states.view.changedWidth;
73224
- const height = this.ctx.nrrd_states.view.changedHeight;
73225
- masterCtx.clearRect(0, 0, width, height);
73226
- // Master stores full-alpha composite; globalAlpha applied in start() render loop.
73227
- for (const layerId of this.ctx.nrrd_states.image.layers) {
73228
- if (!this.ctx.gui_states.layerChannel.layerVisibility[layerId])
73229
- continue;
73230
- const target = this.ctx.protectedData.layerTargets.get(layerId);
73231
- if (target)
73232
- masterCtx.drawImage(target.canvas, 0, 0, width, height);
73233
- }
73234
- }
73235
73236
  // ===== Canvas Cleanup =====
73236
73237
  cleanCanvases(flag) {
73237
73238
  if (flag) {
@@ -73273,7 +73274,7 @@ void main() {
73273
73274
  }
73274
73275
 
73275
73276
  class DragOperator {
73276
- constructor(container, nrrd_sates, gui_states, protectedData, drawingPrameters, setSyncsliceNum, setIsDrawFalse, flipDisplayImageByAxis, setEmptyCanvasSize, getOrCreateSliceBuffer, renderSliceToCanvas) {
73277
+ constructor(container, nrrd_sates, gui_states, protectedData, drawingPrameters, setSyncsliceNum, setIsDrawFalse, flipDisplayImageByAxis, setEmptyCanvasSize, getOrCreateSliceBuffer, renderSliceToCanvas, compositeAllLayers) {
73277
73278
  this.dragPrameters = {
73278
73279
  move: 0,
73279
73280
  y: 0,
@@ -73309,6 +73310,7 @@ void main() {
73309
73310
  this.setEmptyCanvasSize = setEmptyCanvasSize;
73310
73311
  this.getOrCreateSliceBuffer = getOrCreateSliceBuffer;
73311
73312
  this.renderSliceToCanvas = renderSliceToCanvas;
73313
+ this.compositeAllLayers = compositeAllLayers;
73312
73314
  this.showDragNumberDiv = createShowSliceNumberDiv();
73313
73315
  this.init();
73314
73316
  }
@@ -73334,6 +73336,7 @@ void main() {
73334
73336
  setEmptyCanvasSize: (axis) => this.setEmptyCanvasSize(axis),
73335
73337
  getOrCreateSliceBuffer: (axis) => this.getOrCreateSliceBuffer(axis),
73336
73338
  renderSliceToCanvas: (layer, axis, sliceIndex, buffer, targetCtx, w, h) => this.renderSliceToCanvas(layer, axis, sliceIndex, buffer, targetCtx, w, h),
73339
+ compositeAllLayers: () => this.compositeAllLayers(),
73337
73340
  refreshSphereOverlay: () => { var _a; return (_a = this.refreshSphereOverlayCb) === null || _a === void 0 ? void 0 : _a.call(this); },
73338
73341
  }, this.showDragNumberDiv, dragEffectCanvases);
73339
73342
  }
@@ -73366,7 +73369,9 @@ void main() {
73366
73369
  // When leaving draw, contrast, or crosshair mode (returning to idle), restore drag mode
73367
73370
  // Do NOT restore if sphere mode is active
73368
73371
  if ((prev === 'draw' || prev === 'contrast' || prev === 'crosshair') && next === 'idle') {
73369
- if (!this.gui_states.mode.sphere) {
73372
+ if (!this.gui_states.mode.sphere
73373
+ && !this.gui_states.mode.sphereBrush
73374
+ && !this.gui_states.mode.sphereEraser) {
73370
73375
  this.configDragMode();
73371
73376
  }
73372
73377
  }
@@ -73905,6 +73910,7 @@ void main() {
73905
73910
  if (ev.key === this.keyboardSettings.draw) {
73906
73911
  this.state.shiftHeld = true;
73907
73912
  // Block draw mode when crosshair or contrast is active (mutual exclusion)
73913
+ // Also block when sphereBrush or sphereEraser is active
73908
73914
  if (DRAWING_TOOLS.has(this.guiTool) && !this.state.ctrlHeld && !this.state.crosshairEnabled) {
73909
73915
  this.setMode('draw');
73910
73916
  }
@@ -74304,10 +74310,15 @@ void main() {
74304
74310
  configMouseZoomWheel() {
74305
74311
  let moveDistance = 1;
74306
74312
  return (e) => {
74307
- var _a;
74313
+ var _a, _b;
74308
74314
  if ((_a = this.ctx.eventRouter) === null || _a === void 0 ? void 0 : _a.isShiftHeld()) {
74309
74315
  return;
74310
74316
  }
74317
+ // Block zoom wheel when sphereBrush/sphereEraser is actively placing (left button held)
74318
+ if ((this.ctx.gui_states.mode.sphereBrush || this.ctx.gui_states.mode.sphereEraser)
74319
+ && ((_b = this.ctx.eventRouter) === null || _b === void 0 ? void 0 : _b.isLeftButtonDown())) {
74320
+ return;
74321
+ }
74311
74322
  e.preventDefault();
74312
74323
  const delta = e.detail ? e.detail > 0 : e.wheelDelta < 0;
74313
74324
  this.ctx.protectedData.isDrawing = true;
@@ -74909,6 +74920,493 @@ void main() {
74909
74920
  }
74910
74921
  }
74911
74922
 
74923
+ /**
74924
+ * SphereBrushTool — 3D sphere brush and eraser on layer MaskVolume.
74925
+ *
74926
+ * Unlike SphereTool (which uses its own isolated sphereMaskVolume),
74927
+ * this tool writes/erases directly in the active layer's shared MaskVolume —
74928
+ * the same volume used by pencil/brush/eraser tools.
74929
+ *
74930
+ * Two operation modes handled by one class:
74931
+ * - **sphereBrush**: Direct click to paint a 3D sphere of the active channel label.
74932
+ * - **sphereEraser**: Shift+click to erase a 3D sphere (only voxels matching active channel).
74933
+ *
74934
+ * Interaction:
74935
+ * 1. Pointer-down records sphere center, switches wheel to radius mode.
74936
+ * 2. Scroll wheel adjusts sphereBrushRadius [1, 50].
74937
+ * 3. Pointer-up writes/erases sphere to volume, captures grouped undo, refreshes canvas.
74938
+ */
74939
+ class SphereBrushTool extends BaseTool {
74940
+ constructor(ctx, callbacks) {
74941
+ super(ctx);
74942
+ /** Recorded sphere center in canvas mm-space */
74943
+ this.centerX = 0;
74944
+ this.centerY = 0;
74945
+ this.centerSlice = 0;
74946
+ /** Whether a sphere placement is in progress */
74947
+ this.active = false;
74948
+ /** Current operation mode for the active placement */
74949
+ this.mode = "brush";
74950
+ /** Cumulative "before" snapshots for drag-erase undo (z-index → slice data) */
74951
+ this.dragBeforeSnapshots = new Map();
74952
+ /** Whether a drag-erase has actually moved (vs. simple click-release) */
74953
+ this.dragMoved = false;
74954
+ this.callbacks = callbacks;
74955
+ }
74956
+ setCallbacks(callbacks) {
74957
+ this.callbacks = callbacks;
74958
+ }
74959
+ // ── Geometry (ported from SphereTool) ──────────────────────────
74960
+ /**
74961
+ * Convert canvas mm-space coordinates to 3D voxel coordinates.
74962
+ */
74963
+ canvasToVoxelCenter(canvasX, canvasY, sliceIndex, axis) {
74964
+ const nrrd = this.ctx.nrrd_states;
74965
+ switch (axis) {
74966
+ case 'z':
74967
+ return {
74968
+ x: canvasX * nrrd.image.nrrd_x_pixel / nrrd.image.nrrd_x_mm,
74969
+ y: canvasY * nrrd.image.nrrd_y_pixel / nrrd.image.nrrd_y_mm,
74970
+ z: sliceIndex,
74971
+ };
74972
+ case 'y':
74973
+ return {
74974
+ x: canvasX * nrrd.image.nrrd_x_pixel / nrrd.image.nrrd_x_mm,
74975
+ y: sliceIndex,
74976
+ z: (nrrd.image.nrrd_z_mm - canvasY) * nrrd.image.nrrd_z_pixel / nrrd.image.nrrd_z_mm,
74977
+ };
74978
+ case 'x':
74979
+ return {
74980
+ x: sliceIndex,
74981
+ y: canvasY * nrrd.image.nrrd_y_pixel / nrrd.image.nrrd_y_mm,
74982
+ z: canvasX * nrrd.image.nrrd_z_pixel / nrrd.image.nrrd_z_mm,
74983
+ };
74984
+ }
74985
+ }
74986
+ /**
74987
+ * Convert mm radius to per-axis voxel radii.
74988
+ */
74989
+ getVoxelRadii(radius) {
74990
+ const nrrd = this.ctx.nrrd_states;
74991
+ return {
74992
+ rx: radius * nrrd.image.nrrd_x_pixel / nrrd.image.nrrd_x_mm,
74993
+ ry: radius * nrrd.image.nrrd_y_pixel / nrrd.image.nrrd_y_mm,
74994
+ rz: radius * nrrd.image.nrrd_z_pixel / nrrd.image.nrrd_z_mm,
74995
+ };
74996
+ }
74997
+ /**
74998
+ * Compute clamped bounding box for an ellipsoid in voxel space.
74999
+ */
75000
+ getBoundingBox(center, radii, dims) {
75001
+ return {
75002
+ minX: Math.max(0, Math.floor(center.x - radii.rx)),
75003
+ maxX: Math.min(dims.width - 1, Math.ceil(center.x + radii.rx)),
75004
+ minY: Math.max(0, Math.floor(center.y - radii.ry)),
75005
+ maxY: Math.min(dims.height - 1, Math.ceil(center.y + radii.ry)),
75006
+ minZ: Math.max(0, Math.floor(center.z - radii.rz)),
75007
+ maxZ: Math.min(dims.depth - 1, Math.ceil(center.z + radii.rz)),
75008
+ };
75009
+ }
75010
+ // ── 3D Volume Write / Erase ────────────────────────────────────
75011
+ /**
75012
+ * Write a 3D sphere of the active channel label into the layer's MaskVolume.
75013
+ */
75014
+ write3DSphereToBrush(vol, center, radii, bb, label) {
75015
+ const { rx, ry, rz } = radii;
75016
+ for (let z = bb.minZ; z <= bb.maxZ; z++) {
75017
+ for (let y = bb.minY; y <= bb.maxY; y++) {
75018
+ for (let x = bb.minX; x <= bb.maxX; x++) {
75019
+ const dx = rx > 0 ? (x - center.x) / rx : 0;
75020
+ const dy = ry > 0 ? (y - center.y) / ry : 0;
75021
+ const dz = rz > 0 ? (z - center.z) / rz : 0;
75022
+ if (dx * dx + dy * dy + dz * dz <= 1.0) {
75023
+ vol.setVoxel(x, y, z, label);
75024
+ }
75025
+ }
75026
+ }
75027
+ }
75028
+ }
75029
+ /**
75030
+ * Erase a 3D sphere from the layer's MaskVolume.
75031
+ * Only clears voxels whose current value equals the active channel label.
75032
+ */
75033
+ erase3DSphereFromVolume(vol, center, radii, bb, label) {
75034
+ const { rx, ry, rz } = radii;
75035
+ for (let z = bb.minZ; z <= bb.maxZ; z++) {
75036
+ for (let y = bb.minY; y <= bb.maxY; y++) {
75037
+ for (let x = bb.minX; x <= bb.maxX; x++) {
75038
+ const dx = rx > 0 ? (x - center.x) / rx : 0;
75039
+ const dy = ry > 0 ? (y - center.y) / ry : 0;
75040
+ const dz = rz > 0 ? (z - center.z) / rz : 0;
75041
+ if (dx * dx + dy * dy + dz * dz <= 1.0) {
75042
+ if (vol.getVoxel(x, y, z) === label) {
75043
+ vol.setVoxel(x, y, z, 0);
75044
+ }
75045
+ }
75046
+ }
75047
+ }
75048
+ }
75049
+ }
75050
+ // ── Undo Capture ───────────────────────────────────────────────
75051
+ /**
75052
+ * Capture slice snapshots for all Z-slices in the bounding box.
75053
+ * Used before and after the sphere operation to build MaskDelta groups.
75054
+ *
75055
+ * We always capture along the Z axis since the 3D sphere spans Z slices.
75056
+ */
75057
+ captureSliceSnapshots(vol, minZ, maxZ) {
75058
+ const snapshots = new Map();
75059
+ for (let z = minZ; z <= maxZ; z++) {
75060
+ try {
75061
+ const { data } = vol.getSliceUint8(z, 'z');
75062
+ snapshots.set(z, data.slice());
75063
+ }
75064
+ catch (_a) {
75065
+ // slice out of bounds — skip
75066
+ }
75067
+ }
75068
+ return snapshots;
75069
+ }
75070
+ /**
75071
+ * Build MaskDelta group from before/after snapshots.
75072
+ */
75073
+ buildUndoGroup(layerId, before, after) {
75074
+ const deltas = [];
75075
+ for (const [sliceIndex, oldSlice] of before) {
75076
+ const newSlice = after.get(sliceIndex);
75077
+ if (!newSlice)
75078
+ continue;
75079
+ // Only include slices that actually changed
75080
+ let changed = false;
75081
+ for (let i = 0; i < oldSlice.length; i++) {
75082
+ if (oldSlice[i] !== newSlice[i]) {
75083
+ changed = true;
75084
+ break;
75085
+ }
75086
+ }
75087
+ if (changed) {
75088
+ deltas.push({ layerId, axis: 'z', sliceIndex, oldSlice, newSlice });
75089
+ }
75090
+ }
75091
+ return deltas;
75092
+ }
75093
+ // ── Preview Rendering ──────────────────────────────────────────
75094
+ /**
75095
+ * Draw a preview circle on the sphere canvas during sphere placement.
75096
+ */
75097
+ drawPreview(mouseX, mouseY, radius, isEraser) {
75098
+ const sphereCanvas = this.ctx.protectedData.canvases.drawingSphereCanvas;
75099
+ const sphereCtx = this.ctx.protectedData.ctxes.drawingSphereCtx;
75100
+ // Clear and resize sphere canvas
75101
+ sphereCanvas.width = this.ctx.protectedData.canvases.originCanvas.width;
75102
+ sphereCanvas.height = this.ctx.protectedData.canvases.originCanvas.height;
75103
+ sphereCtx.beginPath();
75104
+ sphereCtx.arc(mouseX, mouseY, radius, 0, 2 * Math.PI);
75105
+ if (isEraser) {
75106
+ // Eraser preview: dashed outline
75107
+ sphereCtx.strokeStyle = "#ff4444";
75108
+ sphereCtx.lineWidth = 2;
75109
+ sphereCtx.setLineDash([4, 4]);
75110
+ sphereCtx.stroke();
75111
+ sphereCtx.setLineDash([]);
75112
+ }
75113
+ else {
75114
+ // Brush preview: semi-transparent fill with active channel color
75115
+ const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
75116
+ const color = this.getActiveChannelColor(channel);
75117
+ sphereCtx.fillStyle = color;
75118
+ sphereCtx.globalAlpha = 0.5;
75119
+ sphereCtx.fill();
75120
+ sphereCtx.globalAlpha = 1.0;
75121
+ }
75122
+ sphereCtx.closePath();
75123
+ }
75124
+ /**
75125
+ * Clear the sphere preview canvas.
75126
+ */
75127
+ clearPreview() {
75128
+ const sphereCanvas = this.ctx.protectedData.canvases.drawingSphereCanvas;
75129
+ sphereCanvas.width = sphereCanvas.width;
75130
+ }
75131
+ /**
75132
+ * Get the hex color for the active channel from the layer's MaskVolume.
75133
+ */
75134
+ getActiveChannelColor(channel) {
75135
+ var _a;
75136
+ const layer = this.ctx.gui_states.layerChannel.layer;
75137
+ const volumes = this.ctx.protectedData.maskData.volumes;
75138
+ const vol = volumes[layer];
75139
+ if (vol) {
75140
+ const rgba = vol.getChannelColor(channel);
75141
+ const r = rgba.r.toString(16).padStart(2, '0');
75142
+ const g = rgba.g.toString(16).padStart(2, '0');
75143
+ const b = rgba.b.toString(16).padStart(2, '0');
75144
+ return `#${r}${g}${b}`;
75145
+ }
75146
+ return (_a = CHANNEL_HEX_COLORS[channel]) !== null && _a !== void 0 ? _a : "#ff0000";
75147
+ }
75148
+ // ── Wheel Handler ──────────────────────────────────────────────
75149
+ /**
75150
+ * Returns a wheel event handler for adjusting sphereBrushRadius.
75151
+ * Used in both sphereBrush and sphereEraser modes.
75152
+ */
75153
+ configSphereBrushWheel() {
75154
+ return (e) => {
75155
+ e.preventDefault();
75156
+ const sphere = this.ctx.nrrd_states.sphere;
75157
+ if (e.deltaY < 0) {
75158
+ sphere.sphereBrushRadius += 1;
75159
+ }
75160
+ else {
75161
+ sphere.sphereBrushRadius -= 1;
75162
+ }
75163
+ sphere.sphereBrushRadius = Math.max(1, Math.min(sphere.sphereBrushRadius, 50));
75164
+ // Redraw preview
75165
+ if (this.active) {
75166
+ this.drawPreview(this.centerX, this.centerY, sphere.sphereBrushRadius, this.mode === "eraser");
75167
+ }
75168
+ };
75169
+ }
75170
+ // ── Sphere Brush Click/PointerUp ───────────────────────────────
75171
+ /**
75172
+ * Handle pointer-down in sphereBrush mode (direct click, no Shift needed).
75173
+ */
75174
+ onSphereBrushClick(e) {
75175
+ this.centerX = e.offsetX / this.ctx.nrrd_states.view.sizeFactor;
75176
+ this.centerY = e.offsetY / this.ctx.nrrd_states.view.sizeFactor;
75177
+ this.centerSlice = this.ctx.nrrd_states.view.currentSliceIndex;
75178
+ this.active = true;
75179
+ this.mode = "brush";
75180
+ this.drawPreview(this.centerX, this.centerY, this.ctx.nrrd_states.sphere.sphereBrushRadius, false);
75181
+ }
75182
+ /**
75183
+ * Handle pointer-up in sphereBrush mode — write sphere to volume.
75184
+ */
75185
+ onSphereBrushPointerUp() {
75186
+ if (!this.active || this.mode !== "brush")
75187
+ return;
75188
+ this.active = false;
75189
+ const layer = this.ctx.gui_states.layerChannel.layer;
75190
+ const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
75191
+ const axis = this.ctx.protectedData.axis;
75192
+ const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
75193
+ let vol;
75194
+ try {
75195
+ vol = this.callbacks.getVolumeForLayer(layer);
75196
+ }
75197
+ catch (_a) {
75198
+ this.clearPreview();
75199
+ return;
75200
+ }
75201
+ const dims = vol.getDimensions();
75202
+ const center = this.canvasToVoxelCenter(this.centerX, this.centerY, this.centerSlice, axis);
75203
+ const radii = this.getVoxelRadii(radius);
75204
+ const bb = this.getBoundingBox(center, radii, dims);
75205
+ // Capture pre-write snapshots
75206
+ const before = this.captureSliceSnapshots(vol, bb.minZ, bb.maxZ);
75207
+ // Write sphere
75208
+ this.write3DSphereToBrush(vol, center, radii, bb, channel);
75209
+ // Capture post-write snapshots and push undo group
75210
+ const after = this.captureSliceSnapshots(vol, bb.minZ, bb.maxZ);
75211
+ const deltas = this.buildUndoGroup(layer, before, after);
75212
+ if (deltas.length > 0) {
75213
+ this.callbacks.pushUndoGroup(deltas);
75214
+ }
75215
+ // Refresh display and notify backend of ALL changed slices
75216
+ this.refreshDisplay(layer, deltas);
75217
+ this.clearPreview();
75218
+ }
75219
+ // ── Sphere Eraser Click/PointerUp ──────────────────────────────
75220
+ /**
75221
+ * Handle pointer-down in sphereEraser mode.
75222
+ * Initializes drag tracking for cumulative undo.
75223
+ */
75224
+ onSphereEraserClick(e) {
75225
+ this.centerX = e.offsetX / this.ctx.nrrd_states.view.sizeFactor;
75226
+ this.centerY = e.offsetY / this.ctx.nrrd_states.view.sizeFactor;
75227
+ this.centerSlice = this.ctx.nrrd_states.view.currentSliceIndex;
75228
+ this.active = true;
75229
+ this.mode = "eraser";
75230
+ this.dragMoved = false;
75231
+ this.dragBeforeSnapshots.clear();
75232
+ // Capture initial "before" snapshots for the first sphere position
75233
+ const layer = this.ctx.gui_states.layerChannel.layer;
75234
+ const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
75235
+ try {
75236
+ const vol = this.callbacks.getVolumeForLayer(layer);
75237
+ const dims = vol.getDimensions();
75238
+ const axis = this.ctx.protectedData.axis;
75239
+ const center = this.canvasToVoxelCenter(this.centerX, this.centerY, this.centerSlice, axis);
75240
+ const radii = this.getVoxelRadii(radius);
75241
+ const bb = this.getBoundingBox(center, radii, dims);
75242
+ this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
75243
+ }
75244
+ catch (_a) {
75245
+ // Volume not ready
75246
+ }
75247
+ this.drawPreview(this.centerX, this.centerY, radius, true);
75248
+ }
75249
+ /**
75250
+ * Handle pointer-move in sphereEraser mode — drag to continuously erase.
75251
+ */
75252
+ onSphereEraserMove(e) {
75253
+ if (!this.active || this.mode !== "eraser")
75254
+ return;
75255
+ this.dragMoved = true;
75256
+ const mouseX = e.offsetX / this.ctx.nrrd_states.view.sizeFactor;
75257
+ const mouseY = e.offsetY / this.ctx.nrrd_states.view.sizeFactor;
75258
+ const sliceIndex = this.ctx.nrrd_states.view.currentSliceIndex;
75259
+ const layer = this.ctx.gui_states.layerChannel.layer;
75260
+ const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
75261
+ const axis = this.ctx.protectedData.axis;
75262
+ const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
75263
+ let vol;
75264
+ try {
75265
+ vol = this.callbacks.getVolumeForLayer(layer);
75266
+ }
75267
+ catch (_a) {
75268
+ return;
75269
+ }
75270
+ const dims = vol.getDimensions();
75271
+ const center = this.canvasToVoxelCenter(mouseX, mouseY, sliceIndex, axis);
75272
+ const radii = this.getVoxelRadii(radius);
75273
+ const bb = this.getBoundingBox(center, radii, dims);
75274
+ // Expand "before" snapshots to cover any new Z slices
75275
+ this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
75276
+ // Erase sphere at current position
75277
+ this.erase3DSphereFromVolume(vol, center, radii, bb, channel);
75278
+ // Update preview position
75279
+ this.drawPreview(mouseX, mouseY, radius, true);
75280
+ // Refresh display so user sees the erase in real-time
75281
+ this.refreshDisplay(layer);
75282
+ }
75283
+ /**
75284
+ * Handle pointer-up in sphereEraser mode — finalize erase + push undo group.
75285
+ * Works for both click-release and drag-release.
75286
+ */
75287
+ onSphereEraserPointerUp() {
75288
+ if (!this.active || this.mode !== "eraser")
75289
+ return;
75290
+ this.active = false;
75291
+ const layer = this.ctx.gui_states.layerChannel.layer;
75292
+ const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
75293
+ const axis = this.ctx.protectedData.axis;
75294
+ const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
75295
+ let vol;
75296
+ try {
75297
+ vol = this.callbacks.getVolumeForLayer(layer);
75298
+ }
75299
+ catch (_a) {
75300
+ this.clearPreview();
75301
+ this.dragBeforeSnapshots.clear();
75302
+ return;
75303
+ }
75304
+ // If no drag occurred, erase at the click position (original click-release behavior)
75305
+ if (!this.dragMoved) {
75306
+ const dims = vol.getDimensions();
75307
+ const center = this.canvasToVoxelCenter(this.centerX, this.centerY, this.centerSlice, axis);
75308
+ const radii = this.getVoxelRadii(radius);
75309
+ const bb = this.getBoundingBox(center, radii, dims);
75310
+ this.erase3DSphereFromVolume(vol, center, radii, bb, channel);
75311
+ }
75312
+ // Build undo group from cumulative "before" snapshots vs current state
75313
+ const after = this.captureSliceSnapshots(vol, this.dragMinZ(), this.dragMaxZ());
75314
+ const deltas = this.buildUndoGroup(layer, this.dragBeforeSnapshots, after);
75315
+ if (deltas.length > 0) {
75316
+ this.callbacks.pushUndoGroup(deltas);
75317
+ }
75318
+ // Refresh display and notify backend of ALL changed slices
75319
+ this.refreshDisplay(layer, deltas);
75320
+ this.clearPreview();
75321
+ this.dragBeforeSnapshots.clear();
75322
+ }
75323
+ /**
75324
+ * Expand cumulative "before" snapshots to cover z range [minZ, maxZ].
75325
+ * Only captures slices not yet in the map (preserves original state).
75326
+ */
75327
+ expandDragBeforeSnapshots(vol, minZ, maxZ) {
75328
+ for (let z = minZ; z <= maxZ; z++) {
75329
+ if (!this.dragBeforeSnapshots.has(z)) {
75330
+ try {
75331
+ const { data } = vol.getSliceUint8(z, 'z');
75332
+ this.dragBeforeSnapshots.set(z, data.slice());
75333
+ }
75334
+ catch (_a) {
75335
+ // slice out of bounds
75336
+ }
75337
+ }
75338
+ }
75339
+ }
75340
+ /** Get minimum Z index from drag snapshots */
75341
+ dragMinZ() {
75342
+ let min = Infinity;
75343
+ for (const z of this.dragBeforeSnapshots.keys()) {
75344
+ if (z < min)
75345
+ min = z;
75346
+ }
75347
+ return min === Infinity ? 0 : min;
75348
+ }
75349
+ /** Get maximum Z index from drag snapshots */
75350
+ dragMaxZ() {
75351
+ let max = -Infinity;
75352
+ for (const z of this.dragBeforeSnapshots.keys()) {
75353
+ if (z > max)
75354
+ max = z;
75355
+ }
75356
+ return max === -Infinity ? 0 : max;
75357
+ }
75358
+ // ── Post-write Display Refresh ─────────────────────────────────
75359
+ /**
75360
+ * After writing/erasing the sphere in the volume, re-render the current
75361
+ * slice's layer canvas and composite all layers to master.
75362
+ *
75363
+ * @param changedDeltas - If provided, fire onMaskChanged for ALL changed
75364
+ * slices (not just the current view slice) so the backend receives the
75365
+ * full 3D sphere data for NII/GLTF export.
75366
+ */
75367
+ refreshDisplay(layerId, changedDeltas) {
75368
+ // Re-render layer canvas from volume for the current slice
75369
+ const target = this.ctx.protectedData.layerTargets.get(layerId);
75370
+ if (target) {
75371
+ const { ctx, canvas } = target;
75372
+ canvas.width = canvas.width; // clear
75373
+ const buffer = this.callbacks.getOrCreateSliceBuffer(this.ctx.protectedData.axis);
75374
+ if (buffer) {
75375
+ 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);
75376
+ }
75377
+ }
75378
+ // Composite all layers to master canvas
75379
+ this.callbacks.compositeAllLayers();
75380
+ // Fire onMaskChanged for ALL changed slices (not just the current view slice)
75381
+ // This ensures the backend receives the full 3D sphere data for export.
75382
+ try {
75383
+ const vol = this.callbacks.getVolumeForLayer(layerId);
75384
+ const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
75385
+ if (changedDeltas && changedDeltas.length > 0) {
75386
+ // Report every changed Z slice
75387
+ for (const delta of changedDeltas) {
75388
+ const { data: sliceData, width, height } = vol.getSliceUint8(delta.sliceIndex, delta.axis);
75389
+ this.ctx.callbacks.onMaskChanged(sliceData, layerId, channel, delta.sliceIndex, delta.axis, width, height, false);
75390
+ }
75391
+ }
75392
+ else {
75393
+ // Fallback: report only the current slice
75394
+ const axis = this.ctx.protectedData.axis;
75395
+ const sliceIndex = this.ctx.nrrd_states.view.currentSliceIndex;
75396
+ const { data: sliceData, width, height } = vol.getSliceUint8(sliceIndex, axis);
75397
+ this.ctx.callbacks.onMaskChanged(sliceData, layerId, channel, sliceIndex, axis, width, height, false);
75398
+ }
75399
+ }
75400
+ catch (_a) {
75401
+ // Volume not ready
75402
+ }
75403
+ }
75404
+ /** Whether a sphere placement is currently in progress */
75405
+ get isActive() {
75406
+ return this.active;
75407
+ }
75408
+ }
75409
+
74912
75410
  /**
74913
75411
  * DrawToolCore — Tool orchestration and event routing.
74914
75412
  *
@@ -74929,6 +75427,7 @@ void main() {
74929
75427
  handleOnDrawingBrushCricleMove: (ev) => { },
74930
75428
  handleMouseZoomSliceWheel: (e) => { },
74931
75429
  handleSphereWheel: (e) => { },
75430
+ handleSphereBrushWheel: (e) => { },
74932
75431
  };
74933
75432
  this.contrastEventPrameters = {
74934
75433
  move_x: 0,
@@ -75011,6 +75510,16 @@ void main() {
75011
75510
  pushUndoDelta: (delta) => this.undoManager.push(delta),
75012
75511
  getEraserUrls: () => this.eraserUrls,
75013
75512
  });
75513
+ this.sphereBrushTool = new SphereBrushTool(toolCtx, {
75514
+ getVolumeForLayer: (layer) => this.renderer.getVolumeForLayer(layer),
75515
+ compositeAllLayers: () => this.renderer.compositeAllLayers(),
75516
+ pushUndoGroup: (deltas) => this.undoManager.pushGroup(deltas),
75517
+ renderSliceToCanvas: (layer, axis, sliceIndex, buffer, ctx, w, h) => this.renderer.renderSliceToCanvas(layer, axis, sliceIndex, buffer, ctx, w, h),
75518
+ getOrCreateSliceBuffer: (axis) => this.renderer.getOrCreateSliceBuffer(axis),
75519
+ setEmptyCanvasSize: (axis) => this.setEmptyCanvasSize(axis),
75520
+ reloadMasksFromVolume: () => this.reloadMasksFromVolume(),
75521
+ getEraserUrls: () => this.eraserUrls,
75522
+ });
75014
75523
  }
75015
75524
  initDrawToolCore() {
75016
75525
  // Initialize EventRouter for centralized event handling
@@ -75055,12 +75564,28 @@ void main() {
75055
75564
  if ((ev.ctrlKey || ev.metaKey) && (ev.key === redoKey || (ev.shiftKey && ev.key === undoKeyUpper))) {
75056
75565
  this.redoLastPainting();
75057
75566
  }
75567
+ // Handle mouse wheel mode toggle (Ctrl+1 = Scroll:Zoom, Ctrl+2 = Scroll:Slice)
75568
+ if ((ev.ctrlKey || ev.metaKey) && ev.key === '1') {
75569
+ ev.preventDefault();
75570
+ this.state.keyboardSettings.mouseWheel = 'Scroll:Zoom';
75571
+ this.updateMouseWheelEvent();
75572
+ return;
75573
+ }
75574
+ if ((ev.ctrlKey || ev.metaKey) && ev.key === '2') {
75575
+ ev.preventDefault();
75576
+ this.state.keyboardSettings.mouseWheel = 'Scroll:Slice';
75577
+ this.updateMouseWheelEvent();
75578
+ return;
75579
+ }
75058
75580
  // Handle crosshair toggle (allowed in drawing tools AND sphere mode)
75059
75581
  if (ev.key === this.state.keyboardSettings.crosshair) {
75060
75582
  this.eventRouter.toggleCrosshair();
75061
75583
  }
75062
75584
  // Handle draw mode (Shift key) - EventRouter already tracks this
75063
- if (ev.key === this.state.keyboardSettings.draw && !this.state.gui_states.mode.sphere) {
75585
+ if (ev.key === this.state.keyboardSettings.draw
75586
+ && !this.state.gui_states.mode.sphere
75587
+ && !this.state.gui_states.mode.sphereBrush
75588
+ && !this.state.gui_states.mode.sphereEraser) {
75064
75589
  if (this.eventRouter.isCtrlHeld()) {
75065
75590
  return; // Ctrl takes priority
75066
75591
  }
@@ -75112,11 +75637,16 @@ void main() {
75112
75637
  if (this.drawingTool.isActive || this.panTool.isActive) {
75113
75638
  this.drawingPrameters.handleOnDrawingMouseMove(e);
75114
75639
  }
75640
+ // Drag-erase: route move to sphereBrushTool when sphereEraser is active
75641
+ if (this.sphereBrushTool.isActive && this.state.gui_states.mode.sphereEraser) {
75642
+ this.sphereBrushTool.onSphereEraserMove(e);
75643
+ }
75115
75644
  });
75116
75645
  this.eventRouter.setPointerUpHandler((e) => {
75117
75646
  if (this.drawingTool.isActive || this.drawingTool.painting
75118
75647
  || this.panTool.isActive
75119
- || (this.state.gui_states.mode.sphere && this.eventRouter.getMode() !== 'crosshair')) {
75648
+ || (this.state.gui_states.mode.sphere && this.eventRouter.getMode() !== 'crosshair')
75649
+ || this.sphereBrushTool.isActive) {
75120
75650
  this.drawingPrameters.handleOnDrawingMouseUp(e);
75121
75651
  }
75122
75652
  });
@@ -75131,6 +75661,9 @@ void main() {
75131
75661
  else if (this.activeWheelMode === 'sphere') {
75132
75662
  this.drawingPrameters.handleSphereWheel(e);
75133
75663
  }
75664
+ else if (this.activeWheelMode === 'sphereBrush') {
75665
+ this.drawingPrameters.handleSphereBrushWheel(e);
75666
+ }
75134
75667
  });
75135
75668
  // Bind all event listeners
75136
75669
  this.eventRouter.bindAll();
@@ -75194,6 +75727,8 @@ void main() {
75194
75727
  }
75195
75728
  // sphere Wheel
75196
75729
  this.drawingPrameters.handleSphereWheel = this.configMouseSphereWheel();
75730
+ // sphere brush Wheel
75731
+ this.drawingPrameters.handleSphereBrushWheel = this.sphereBrushTool.configSphereBrushWheel();
75197
75732
  // brush circle move — delegated to DrawingTool
75198
75733
  this.drawingPrameters.handleOnDrawingBrushCricleMove =
75199
75734
  this.drawingTool.createBrushTrackingHandler();
@@ -75224,6 +75759,16 @@ void main() {
75224
75759
  e.offsetY / this.state.nrrd_states.view.sizeFactor;
75225
75760
  this.enableCrosshair();
75226
75761
  }
75762
+ else if (this.state.gui_states.mode.sphereBrush && !this.eventRouter.isCrosshairEnabled()) {
75763
+ // SphereBrush: direct click (no Shift needed)
75764
+ this.activeWheelMode = 'sphereBrush';
75765
+ this.sphereBrushTool.onSphereBrushClick(e);
75766
+ }
75767
+ else if (this.state.gui_states.mode.sphereEraser && !this.eventRouter.isCrosshairEnabled()) {
75768
+ // SphereEraser: direct click (Shift handled at mode level)
75769
+ this.activeWheelMode = 'sphereBrush';
75770
+ this.sphereBrushTool.onSphereEraserClick(e);
75771
+ }
75227
75772
  else if (this.state.gui_states.mode.sphere && !this.eventRouter.isCrosshairEnabled()) {
75228
75773
  this.handleSphereClick(e);
75229
75774
  }
@@ -75245,6 +75790,16 @@ void main() {
75245
75790
  this.drawingTool.onPointerUp(e);
75246
75791
  this.activeWheelMode = 'zoom';
75247
75792
  }
75793
+ else if (this.sphereBrushTool.isActive) {
75794
+ // SphereBrush or SphereEraser pointer-up
75795
+ if (this.state.gui_states.mode.sphereBrush) {
75796
+ this.sphereBrushTool.onSphereBrushPointerUp();
75797
+ }
75798
+ else if (this.state.gui_states.mode.sphereEraser) {
75799
+ this.sphereBrushTool.onSphereEraserPointerUp();
75800
+ }
75801
+ this.activeWheelMode = 'zoom';
75802
+ }
75248
75803
  else if (this.state.gui_states.mode.sphere &&
75249
75804
  !this.eventRouter.isCrosshairEnabled()) {
75250
75805
  this.sphereTool.onSpherePointerUp();
@@ -75289,7 +75844,9 @@ void main() {
75289
75844
  }
75290
75845
  }
75291
75846
  this.state.protectedData.ctxes.drawingCtx.drawImage(this.state.protectedData.canvases.drawingCanvasLayerMaster, 0, 0);
75292
- if (this.state.gui_states.mode.sphere) {
75847
+ if (this.state.gui_states.mode.sphere
75848
+ || this.state.gui_states.mode.sphereBrush
75849
+ || this.state.gui_states.mode.sphereEraser) {
75293
75850
  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);
75294
75851
  }
75295
75852
  }
@@ -75476,24 +76033,26 @@ void main() {
75476
76033
  * Undo the last drawing operation on the active layer.
75477
76034
  */
75478
76035
  undoLastPainting() {
75479
- const delta = this.undoManager.undo();
75480
- if (!delta)
75481
- return;
75482
- try {
75483
- const vol = this.renderer.getVolumeForLayer(delta.layerId);
75484
- vol.setSliceUint8(delta.sliceIndex, delta.oldSlice, delta.axis);
75485
- }
75486
- catch (_a) {
76036
+ const entry = this.undoManager.undo();
76037
+ if (!entry)
75487
76038
  return;
75488
- }
75489
76039
  this.state.protectedData.isDrawing = true;
75490
- if (delta.axis === this.state.protectedData.axis && delta.sliceIndex === this.state.nrrd_states.view.currentSliceIndex) {
75491
- this.applyUndoRedoToCanvas(delta.layerId);
75492
- }
75493
- if (!this.state.nrrd_states.flags.loadingMaskData) {
75494
- const { data: sliceData, width, height } = this.renderer.getVolumeForLayer(delta.layerId)
75495
- .getSliceUint8(delta.sliceIndex, delta.axis);
75496
- this.state.annotationCallbacks.onMaskChanged(sliceData, delta.layerId, this.state.gui_states.layerChannel.activeChannel || 1, delta.sliceIndex, delta.axis, width, height, false);
76040
+ for (const delta of entry) {
76041
+ try {
76042
+ const vol = this.renderer.getVolumeForLayer(delta.layerId);
76043
+ vol.setSliceUint8(delta.sliceIndex, delta.oldSlice, delta.axis);
76044
+ }
76045
+ catch (_a) {
76046
+ continue;
76047
+ }
76048
+ if (delta.axis === this.state.protectedData.axis && delta.sliceIndex === this.state.nrrd_states.view.currentSliceIndex) {
76049
+ this.applyUndoRedoToCanvas(delta.layerId);
76050
+ }
76051
+ if (!this.state.nrrd_states.flags.loadingMaskData) {
76052
+ const { data: sliceData, width, height } = this.renderer.getVolumeForLayer(delta.layerId)
76053
+ .getSliceUint8(delta.sliceIndex, delta.axis);
76054
+ this.state.annotationCallbacks.onMaskChanged(sliceData, delta.layerId, this.state.gui_states.layerChannel.activeChannel || 1, delta.sliceIndex, delta.axis, width, height, false);
76055
+ }
75497
76056
  }
75498
76057
  this.setIsDrawFalse(1000);
75499
76058
  }
@@ -75501,24 +76060,26 @@ void main() {
75501
76060
  * Redo the last undone operation on the active layer.
75502
76061
  */
75503
76062
  redoLastPainting() {
75504
- const delta = this.undoManager.redo();
75505
- if (!delta)
76063
+ const entry = this.undoManager.redo();
76064
+ if (!entry)
75506
76065
  return;
75507
- try {
75508
- const vol = this.renderer.getVolumeForLayer(delta.layerId);
75509
- vol.setSliceUint8(delta.sliceIndex, delta.newSlice, delta.axis);
75510
- }
75511
- catch (_a) {
75512
- return;
75513
- }
75514
76066
  this.state.protectedData.isDrawing = true;
75515
- if (delta.axis === this.state.protectedData.axis && delta.sliceIndex === this.state.nrrd_states.view.currentSliceIndex) {
75516
- this.applyUndoRedoToCanvas(delta.layerId);
75517
- }
75518
- if (!this.state.nrrd_states.flags.loadingMaskData) {
75519
- const { data: sliceData, width, height } = this.renderer.getVolumeForLayer(delta.layerId)
75520
- .getSliceUint8(delta.sliceIndex, delta.axis);
75521
- this.state.annotationCallbacks.onMaskChanged(sliceData, delta.layerId, this.state.gui_states.layerChannel.activeChannel || 1, delta.sliceIndex, delta.axis, width, height, false);
76067
+ for (const delta of entry) {
76068
+ try {
76069
+ const vol = this.renderer.getVolumeForLayer(delta.layerId);
76070
+ vol.setSliceUint8(delta.sliceIndex, delta.newSlice, delta.axis);
76071
+ }
76072
+ catch (_a) {
76073
+ continue;
76074
+ }
76075
+ if (delta.axis === this.state.protectedData.axis && delta.sliceIndex === this.state.nrrd_states.view.currentSliceIndex) {
76076
+ this.applyUndoRedoToCanvas(delta.layerId);
76077
+ }
76078
+ if (!this.state.nrrd_states.flags.loadingMaskData) {
76079
+ const { data: sliceData, width, height } = this.renderer.getVolumeForLayer(delta.layerId)
76080
+ .getSliceUint8(delta.sliceIndex, delta.axis);
76081
+ this.state.annotationCallbacks.onMaskChanged(sliceData, delta.layerId, this.state.gui_states.layerChannel.activeChannel || 1, delta.sliceIndex, delta.axis, width, height, false);
76082
+ }
75522
76083
  }
75523
76084
  this.setIsDrawFalse(1000);
75524
76085
  }
@@ -75592,6 +76153,14 @@ void main() {
75592
76153
  exitSphereMode() {
75593
76154
  throw new Error("exitSphereMode must be provided by NrrdTools");
75594
76155
  }
76156
+ /** Override in NrrdTools */
76157
+ reloadMasksFromVolume() {
76158
+ throw new Error("reloadMasksFromVolume must be provided by NrrdTools");
76159
+ }
76160
+ /** Override in NrrdTools */
76161
+ updateMouseWheelEvent() {
76162
+ throw new Error("updateMouseWheelEvent must be provided by NrrdTools");
76163
+ }
75595
76164
  }
75596
76165
 
75597
76166
  /**
@@ -75655,6 +76224,7 @@ void main() {
75655
76224
  nippleSphereOrigin: null,
75656
76225
  sphereMaskVolume: null,
75657
76226
  sphereRadius: 5,
76227
+ sphereBrushRadius: 10,
75658
76228
  };
75659
76229
  this.flags = {
75660
76230
  stepClear: 1,
@@ -75676,6 +76246,7 @@ void main() {
75676
76246
  this.sphere.nippleSphereOrigin = null;
75677
76247
  this.sphere.sphereMaskVolume = null;
75678
76248
  this.sphere.sphereRadius = 5;
76249
+ this.sphere.sphereBrushRadius = 10;
75679
76250
  }
75680
76251
  }
75681
76252
 
@@ -75697,6 +76268,8 @@ void main() {
75697
76268
  pencil: true,
75698
76269
  eraser: false,
75699
76270
  sphere: false,
76271
+ sphereBrush: false,
76272
+ sphereEraser: false,
75700
76273
  activeSphereType: "tumour",
75701
76274
  };
75702
76275
  this.drawing = {
@@ -75891,6 +76464,216 @@ void main() {
75891
76464
  }
75892
76465
  }
75893
76466
 
76467
+ /**
76468
+ * GaussianSmoother — 3D Gaussian smoothing for segmentation masks.
76469
+ *
76470
+ * Applies a separable 3D Gaussian blur to a single label channel within a
76471
+ * MaskVolume, then thresholds the result back to a binary mask. This smooths
76472
+ * jagged edges and fills small holes in segmentation annotations.
76473
+ *
76474
+ * Pure, stateless utility — no DOM/Canvas/GUI dependencies.
76475
+ *
76476
+ * Performance: uses direct typed-array access (bypassing getVoxel/setVoxel
76477
+ * boundary checks) and branch-free convolution for the interior region.
76478
+ */
76479
+ class GaussianSmoother {
76480
+ /**
76481
+ * Smooth a single label channel in-place using separable 3D Gaussian blur.
76482
+ *
76483
+ * Steps:
76484
+ * 1. Extract — create a Float32Array with 1.0 where voxel === channel, 0.0 elsewhere.
76485
+ * 2. Blur — apply separable Gaussian (X → Y → Z) on the float buffer.
76486
+ * 3. Threshold — binarize at 0.5.
76487
+ * 4. Write back — overwrite/erase voxels according to the thresholded result.
76488
+ *
76489
+ * @param volume The MaskVolume to operate on (modified in-place).
76490
+ * @param channel The label value to smooth (must be > 0).
76491
+ * @param sigma Base Gaussian sigma in voxel units (default 1.0).
76492
+ * @param spacing Optional voxel spacing [sx, sy, sz]. When provided, per-axis
76493
+ * sigma is computed as `sigma / spacing[axis]` so that the
76494
+ * physical smoothing radius is isotropic.
76495
+ */
76496
+ static gaussianSmooth3D(volume, channel, sigma = 1.0, spacing) {
76497
+ const dims = volume.getDimensions();
76498
+ const { width, height, depth } = dims;
76499
+ const totalVoxels = width * height * depth;
76500
+ // Direct access to underlying buffer — bypasses getVoxel/setVoxel
76501
+ // boundary checks for ~10x speedup in hot loops.
76502
+ const rawData = volume.getRawData();
76503
+ const channels = volume.getChannels();
76504
+ const bytesPerSlice = volume.getBytesPerSlice();
76505
+ const rowStride = width * channels;
76506
+ // Compute per-axis sigma (normalized to voxel units)
76507
+ const sigmaX = spacing ? sigma / spacing[0] : sigma;
76508
+ const sigmaY = spacing ? sigma / spacing[1] : sigma;
76509
+ const sigmaZ = spacing ? sigma / spacing[2] : sigma;
76510
+ // 1. Extract binary float buffer — direct array access
76511
+ const buffer = new Float32Array(totalVoxels);
76512
+ for (let z = 0; z < depth; z++) {
76513
+ const zOffset = z * bytesPerSlice;
76514
+ for (let y = 0; y < height; y++) {
76515
+ const zyOffset = zOffset + y * rowStride;
76516
+ for (let x = 0; x < width; x++) {
76517
+ const bufIdx = z * height * width + y * width + x;
76518
+ buffer[bufIdx] = rawData[zyOffset + x * channels] === channel ? 1.0 : 0.0;
76519
+ }
76520
+ }
76521
+ }
76522
+ // 2. Separable Gaussian blur: X → Y → Z
76523
+ const kernelX = GaussianSmoother.generateKernel1D(sigmaX);
76524
+ const kernelY = GaussianSmoother.generateKernel1D(sigmaY);
76525
+ const kernelZ = GaussianSmoother.generateKernel1D(sigmaZ);
76526
+ GaussianSmoother.convolve1D(buffer, width, height, depth, 0, kernelX); // X axis
76527
+ GaussianSmoother.convolve1D(buffer, width, height, depth, 1, kernelY); // Y axis
76528
+ GaussianSmoother.convolve1D(buffer, width, height, depth, 2, kernelZ); // Z axis
76529
+ // 3 & 4. Threshold and write back — direct array access
76530
+ for (let z = 0; z < depth; z++) {
76531
+ const zOffset = z * bytesPerSlice;
76532
+ for (let y = 0; y < height; y++) {
76533
+ const zyOffset = zOffset + y * rowStride;
76534
+ for (let x = 0; x < width; x++) {
76535
+ const bufIdx = z * height * width + y * width + x;
76536
+ const smoothed = buffer[bufIdx] >= 0.5 ? 1 : 0;
76537
+ const rawIdx = zyOffset + x * channels;
76538
+ const original = rawData[rawIdx];
76539
+ if (smoothed === 1 && original !== channel) {
76540
+ // Smoothed region expands into this voxel — overwrite
76541
+ rawData[rawIdx] = channel;
76542
+ }
76543
+ else if (smoothed === 0 && original === channel) {
76544
+ // Smoothed region retreats — erase
76545
+ rawData[rawIdx] = 0;
76546
+ }
76547
+ // Otherwise leave unchanged
76548
+ }
76549
+ }
76550
+ }
76551
+ }
76552
+ /**
76553
+ * Generate a normalized 1D Gaussian kernel truncated at ±3σ.
76554
+ *
76555
+ * @param sigma Standard deviation (in voxels). Must be > 0.
76556
+ * @returns Normalized Float32Array of odd length.
76557
+ */
76558
+ static generateKernel1D(sigma) {
76559
+ if (sigma <= 0) {
76560
+ return new Float32Array([1.0]);
76561
+ }
76562
+ const radius = Math.ceil(sigma * 3);
76563
+ const size = 2 * radius + 1;
76564
+ const kernel = new Float32Array(size);
76565
+ const twoSigmaSq = 2 * sigma * sigma;
76566
+ let sum = 0;
76567
+ for (let i = 0; i < size; i++) {
76568
+ const x = i - radius;
76569
+ kernel[i] = Math.exp(-(x * x) / twoSigmaSq);
76570
+ sum += kernel[i];
76571
+ }
76572
+ // Normalize
76573
+ for (let i = 0; i < size; i++) {
76574
+ kernel[i] /= sum;
76575
+ }
76576
+ return kernel;
76577
+ }
76578
+ /**
76579
+ * In-place single-axis convolution with zero-padding at boundaries.
76580
+ *
76581
+ * Uses a 3-segment approach: left boundary (with bounds check),
76582
+ * middle interior (branch-free, ~95% of work), right boundary
76583
+ * (with bounds check). This eliminates per-element branching
76584
+ * in the hot inner loop.
76585
+ *
76586
+ * @param data Flat float buffer (width × height × depth).
76587
+ * @param width X dimension.
76588
+ * @param height Y dimension.
76589
+ * @param depth Z dimension.
76590
+ * @param axis 0 = X, 1 = Y, 2 = Z.
76591
+ * @param kernel 1D convolution kernel (odd length).
76592
+ */
76593
+ static convolve1D(data, width, height, depth, axis, kernel) {
76594
+ const kLen = kernel.length;
76595
+ const radius = (kLen - 1) / 2;
76596
+ // Determine the dimension length along the convolution axis
76597
+ // and the stride between consecutive elements along that axis
76598
+ let axisLen;
76599
+ let stride;
76600
+ let outerCount;
76601
+ if (axis === 0) {
76602
+ // X axis: stride = 1, iterate over all (y, z) lines
76603
+ axisLen = width;
76604
+ stride = 1;
76605
+ outerCount = height * depth;
76606
+ }
76607
+ else if (axis === 1) {
76608
+ // Y axis: stride = width, iterate over all (x, z) lines
76609
+ axisLen = height;
76610
+ stride = width;
76611
+ outerCount = width * depth;
76612
+ }
76613
+ else {
76614
+ // Z axis: stride = width * height, iterate over all (x, y) lines
76615
+ axisLen = depth;
76616
+ stride = width * height;
76617
+ outerCount = width * height;
76618
+ }
76619
+ // Precompute middle segment bounds
76620
+ const midStart = radius;
76621
+ const midEnd = axisLen - radius;
76622
+ // Temporary line buffer to avoid read-after-write issues
76623
+ const line = new Float32Array(axisLen);
76624
+ for (let outer = 0; outer < outerCount; outer++) {
76625
+ // Compute the starting index for this line
76626
+ let lineStart;
76627
+ if (axis === 0) {
76628
+ lineStart = outer * width;
76629
+ }
76630
+ else if (axis === 1) {
76631
+ const z = Math.floor(outer / width);
76632
+ const x = outer % width;
76633
+ lineStart = z * width * height + x;
76634
+ }
76635
+ else {
76636
+ lineStart = outer;
76637
+ }
76638
+ // Read the line
76639
+ for (let i = 0; i < axisLen; i++) {
76640
+ line[i] = data[lineStart + i * stride];
76641
+ }
76642
+ // Left boundary segment (i = 0 .. radius-1): lower bound check
76643
+ for (let i = 0; i < midStart; i++) {
76644
+ let sum = 0;
76645
+ for (let k = 0; k < kLen; k++) {
76646
+ const j = i + k - radius;
76647
+ if (j >= 0) {
76648
+ sum += line[j] * kernel[k];
76649
+ }
76650
+ }
76651
+ data[lineStart + i * stride] = sum;
76652
+ }
76653
+ // Middle segment (i = radius .. axisLen-radius-1): NO bounds check
76654
+ for (let i = midStart; i < midEnd; i++) {
76655
+ let sum = 0;
76656
+ const lineOffset = i - radius;
76657
+ for (let k = 0; k < kLen; k++) {
76658
+ sum += line[lineOffset + k] * kernel[k];
76659
+ }
76660
+ data[lineStart + i * stride] = sum;
76661
+ }
76662
+ // Right boundary segment (i = axisLen-radius .. axisLen-1): upper bound check
76663
+ for (let i = midEnd; i < axisLen; i++) {
76664
+ let sum = 0;
76665
+ for (let k = 0; k < kLen; k++) {
76666
+ const j = i + k - radius;
76667
+ if (j < axisLen) {
76668
+ sum += line[j] * kernel[k];
76669
+ }
76670
+ }
76671
+ data[lineStart + i * stride] = sum;
76672
+ }
76673
+ }
76674
+ }
76675
+ }
76676
+
75894
76677
  /**
75895
76678
  * Manages layer/channel state: active selection, visibility, and channel colors.
75896
76679
  *
@@ -76407,6 +77190,8 @@ void main() {
76407
77190
  * Then update the changedWidth and changedHeight based on the sizeFactor.
76408
77191
  */
76409
77192
  updateOriginAndChangedWH() {
77193
+ const prevW = this.ctx.nrrd_states.image.originWidth;
77194
+ const prevH = this.ctx.nrrd_states.image.originHeight;
76410
77195
  this.ctx.nrrd_states.image.originWidth =
76411
77196
  this.ctx.protectedData.canvases.originCanvas.width;
76412
77197
  this.ctx.nrrd_states.image.originHeight =
@@ -76415,7 +77200,12 @@ void main() {
76415
77200
  // Setting them here would defeat the sizeChanged detection in resizePaintArea,
76416
77201
  // causing canvas elements to keep stale dimensions after axis switches.
76417
77202
  this.resizePaintArea(this.ctx.nrrd_states.view.sizeFactor);
76418
- this.resetPaintAreaUIPosition();
77203
+ // Only re-center when origin dimensions changed (e.g. axis switch).
77204
+ // Contrast toggle doesn't change origin size, so skip to preserve user's pan/zoom.
77205
+ if (prevW !== this.ctx.nrrd_states.image.originWidth ||
77206
+ prevH !== this.ctx.nrrd_states.image.originHeight) {
77207
+ this.resetPaintAreaUIPosition();
77208
+ }
76419
77209
  }
76420
77210
  /**
76421
77211
  * Keep all contrast slice index to same.
@@ -76629,7 +77419,7 @@ void main() {
76629
77419
  // Wire RenderingUtils' setEmptyCanvasSize callback
76630
77420
  this.drawCore.renderer.setEmptyCanvasSize = (axis) => this.setEmptyCanvasSize(axis);
76631
77421
  this.init();
76632
- 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));
77422
+ 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));
76633
77423
  // Inject EventRouter into DragOperator for centralized event handling
76634
77424
  if (this.drawCore.eventRouter) {
76635
77425
  this.dragOperator.setEventRouter(this.drawCore.eventRouter);
@@ -76656,6 +77446,8 @@ void main() {
76656
77446
  this.drawCore.enterSphereMode = () => this.enterSphereMode();
76657
77447
  this.drawCore.exitSphereMode = () => this.exitSphereMode();
76658
77448
  this.drawCore.configMouseSliceWheel = () => this.configMouseSliceWheel();
77449
+ this.drawCore.reloadMasksFromVolume = () => this.reloadMasksFromVolume();
77450
+ this.drawCore.updateMouseWheelEvent = () => this.updateMouseWheelEvent();
76659
77451
  }
76660
77452
  /**
76661
77453
  * A initialise function for nrrd_tools
@@ -76776,13 +77568,18 @@ void main() {
76776
77568
  * Set the current tool mode.
76777
77569
  */
76778
77570
  setMode(mode) {
77571
+ var _a, _b, _c;
76779
77572
  if (!this.guiCallbacks)
76780
77573
  return;
76781
77574
  const prevSphere = this.state.gui_states.mode.sphere;
77575
+ const prevSphereBrush = this.state.gui_states.mode.sphereBrush;
77576
+ const prevSphereEraser = this.state.gui_states.mode.sphereEraser;
76782
77577
  const prevCalculator = this._calculatorActive;
76783
77578
  this.state.gui_states.mode.pencil = false;
76784
77579
  this.state.gui_states.mode.eraser = false;
76785
77580
  this.state.gui_states.mode.sphere = false;
77581
+ this.state.gui_states.mode.sphereBrush = false;
77582
+ this.state.gui_states.mode.sphereEraser = false;
76786
77583
  this._calculatorActive = false;
76787
77584
  switch (mode) {
76788
77585
  case "pencil":
@@ -76803,6 +77600,24 @@ void main() {
76803
77600
  this.state.gui_states.mode.sphere = true;
76804
77601
  this._calculatorActive = true;
76805
77602
  break;
77603
+ case "sphereBrush":
77604
+ this.state.gui_states.mode.sphereBrush = true;
77605
+ this.dragOperator.removeDragMode();
77606
+ (_a = this.drawCore.eventRouter) === null || _a === void 0 ? void 0 : _a.setGuiTool('sphereBrush');
77607
+ break;
77608
+ case "sphereEraser":
77609
+ this.state.gui_states.mode.sphereEraser = true;
77610
+ this.dragOperator.removeDragMode();
77611
+ (_b = this.drawCore.eventRouter) === null || _b === void 0 ? void 0 : _b.setGuiTool('sphereEraser');
77612
+ break;
77613
+ }
77614
+ // Restore drag mode when leaving sphereBrush/sphereEraser
77615
+ if ((prevSphereBrush || prevSphereEraser)
77616
+ && !this.state.gui_states.mode.sphereBrush
77617
+ && !this.state.gui_states.mode.sphereEraser
77618
+ && !this.state.gui_states.mode.sphere) {
77619
+ this.dragOperator.configDragMode();
77620
+ (_c = this.drawCore.eventRouter) === null || _c === void 0 ? void 0 : _c.setGuiTool('pencil');
76806
77621
  }
76807
77622
  if (prevSphere && !this.state.gui_states.mode.sphere) {
76808
77623
  this.guiCallbacks.updateSphereState();
@@ -76815,6 +77630,10 @@ void main() {
76815
77630
  getMode() {
76816
77631
  if (this._calculatorActive)
76817
77632
  return "calculator";
77633
+ if (this.state.gui_states.mode.sphereBrush)
77634
+ return "sphereBrush";
77635
+ if (this.state.gui_states.mode.sphereEraser)
77636
+ return "sphereEraser";
76818
77637
  if (this.state.gui_states.mode.sphere)
76819
77638
  return "sphere";
76820
77639
  if (this.state.gui_states.mode.eraser)
@@ -76840,6 +77659,12 @@ void main() {
76840
77659
  getBrushSize() {
76841
77660
  return this.state.gui_states.drawing.brushAndEraserSize;
76842
77661
  }
77662
+ setSphereBrushRadius(radius) {
77663
+ this.state.nrrd_states.sphere.sphereBrushRadius = Math.max(1, Math.min(50, radius));
77664
+ }
77665
+ getSphereBrushRadius() {
77666
+ return this.state.nrrd_states.sphere.sphereBrushRadius;
77667
+ }
76843
77668
  setWindowHigh(value) {
76844
77669
  var _a;
76845
77670
  this.state.gui_states.viewConfig.readyToUpdate = false;
@@ -76913,7 +77738,8 @@ void main() {
76913
77738
  getPencilColor() {
76914
77739
  return this.state.gui_states.drawing.color;
76915
77740
  }
76916
- executeAction(action) {
77741
+ executeAction(action, opts) {
77742
+ var _a;
76917
77743
  switch (action) {
76918
77744
  case "undo":
76919
77745
  this.undo();
@@ -76951,6 +77777,48 @@ void main() {
76951
77777
  enableDownload(config);
76952
77778
  break;
76953
77779
  }
77780
+ case "gaussianSmooth": {
77781
+ const layerId = this.state.gui_states.layerChannel.layer;
77782
+ const channel = this.state.gui_states.layerChannel.activeChannel;
77783
+ const volume = this.drawCore.renderer.getVolumeForLayer(layerId);
77784
+ if (volume && channel > 0) {
77785
+ const dims = volume.getDimensions();
77786
+ // 1. Snapshot all Z-slices before mutation (undo support)
77787
+ const deltas = [];
77788
+ for (let z = 0; z < dims.depth; z++) {
77789
+ const sliceData = volume.getSliceUint8(z, "z").data;
77790
+ const hasChannel = sliceData.some((v) => v === channel);
77791
+ if (hasChannel) {
77792
+ deltas.push({
77793
+ layerId,
77794
+ axis: "z",
77795
+ sliceIndex: z,
77796
+ oldSlice: sliceData.slice(),
77797
+ newSlice: new Uint8Array(0), // placeholder, filled after smoothing
77798
+ });
77799
+ }
77800
+ }
77801
+ // 2. Apply smoothing with anisotropic spacing
77802
+ const spacing = this.state.nrrd_states.image.voxelSpacing;
77803
+ const spacingTuple = spacing.length >= 3 ? [spacing[0], spacing[1], spacing[2]] : undefined;
77804
+ const sigma = (_a = opts === null || opts === void 0 ? void 0 : opts.sigma) !== null && _a !== void 0 ? _a : 1.0;
77805
+ GaussianSmoother.gaussianSmooth3D(volume, channel, sigma, spacingTuple);
77806
+ // 3. Capture newSlice data after smoothing and push undo group
77807
+ for (const delta of deltas) {
77808
+ delta.newSlice = volume.getSliceUint8(delta.sliceIndex, "z").data.slice();
77809
+ }
77810
+ if (deltas.length > 0) {
77811
+ this.drawCore.undoManager.pushGroup(deltas);
77812
+ }
77813
+ // 4. Notify backend for each changed slice
77814
+ for (const delta of deltas) {
77815
+ const { data: sliceData, width, height } = volume.getSliceUint8(delta.sliceIndex, "z");
77816
+ this.state.annotationCallbacks.onMaskChanged(sliceData, layerId, channel, delta.sliceIndex, "z", width, height, false);
77817
+ }
77818
+ this.reloadMasksFromVolume();
77819
+ }
77820
+ break;
77821
+ }
76954
77822
  }
76955
77823
  }
76956
77824
  // ═══════════════════════════════════════════════════════════════════════════
@@ -77389,10 +78257,15 @@ void main() {
77389
78257
  // ═══════════════════════════════════════════════════════════════════════════
77390
78258
  configMouseSliceWheel() {
77391
78259
  const handleMouseZoomSliceWheelMove = (e) => {
77392
- var _a;
78260
+ var _a, _b;
77393
78261
  if ((_a = this.drawCore.eventRouter) === null || _a === void 0 ? void 0 : _a.isShiftHeld()) {
77394
78262
  return;
77395
78263
  }
78264
+ // Block slice wheel when sphereBrush/sphereEraser is actively placing (left button held)
78265
+ if ((this.state.gui_states.mode.sphereBrush || this.state.gui_states.mode.sphereEraser)
78266
+ && ((_b = this.drawCore.eventRouter) === null || _b === void 0 ? void 0 : _b.isLeftButtonDown())) {
78267
+ return;
78268
+ }
77396
78269
  e.preventDefault();
77397
78270
  if (e.deltaY < 0) {
77398
78271
  this.setSliceMoving(-1);
@@ -77660,13 +78533,14 @@ void main() {
77660
78533
  }
77661
78534
 
77662
78535
  // import * as kiwrious from "copper3d_plugin_heart_k";
77663
- const REVISION = "v3.1.2-beta";
78536
+ const REVISION = "v3.2.1-beta";
77664
78537
  console.log(`%cCopper3D Visualisation %cBeta:${REVISION}`, "padding: 3px;color:white; background:#023047", "padding: 3px;color:white; background:#f50a25");
77665
78538
 
77666
78539
  exports.CHANNEL_COLORS = CHANNEL_COLORS;
77667
78540
  exports.CHANNEL_HEX_COLORS = CHANNEL_HEX_COLORS;
77668
78541
  exports.CameraViewPoint = CameraViewPoint;
77669
78542
  exports.Copper3dTrackballControls = Copper3dTrackballControls;
78543
+ exports.GaussianSmoother = GaussianSmoother;
77670
78544
  exports.MeshNodeTool = MeshNodeTool;
77671
78545
  exports.NrrdTools = NrrdTools;
77672
78546
  exports.REVISION = REVISION;