copper3d 3.1.2 → 3.2.0

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 (45) hide show
  1. package/dist/Utils/segmentation/DragOperator.js +3 -1
  2. package/dist/Utils/segmentation/DragOperator.js.map +1 -1
  3. package/dist/Utils/segmentation/DrawToolCore.d.ts +6 -0
  4. package/dist/Utils/segmentation/DrawToolCore.js +107 -35
  5. package/dist/Utils/segmentation/DrawToolCore.js.map +1 -1
  6. package/dist/Utils/segmentation/NrrdTools.d.ts +2 -0
  7. package/dist/Utils/segmentation/NrrdTools.js +41 -1
  8. package/dist/Utils/segmentation/NrrdTools.js.map +1 -1
  9. package/dist/Utils/segmentation/core/UndoManager.d.ts +9 -5
  10. package/dist/Utils/segmentation/core/UndoManager.js +21 -14
  11. package/dist/Utils/segmentation/core/UndoManager.js.map +1 -1
  12. package/dist/Utils/segmentation/core/index.d.ts +1 -1
  13. package/dist/Utils/segmentation/core/types.d.ts +5 -1
  14. package/dist/Utils/segmentation/core/types.js.map +1 -1
  15. package/dist/Utils/segmentation/coreTools/GuiState.js +2 -0
  16. package/dist/Utils/segmentation/coreTools/GuiState.js.map +1 -1
  17. package/dist/Utils/segmentation/coreTools/NrrdState.js +2 -0
  18. package/dist/Utils/segmentation/coreTools/NrrdState.js.map +1 -1
  19. package/dist/Utils/segmentation/eventRouter/EventRouter.js +1 -0
  20. package/dist/Utils/segmentation/eventRouter/EventRouter.js.map +1 -1
  21. package/dist/Utils/segmentation/eventRouter/types.d.ts +1 -1
  22. package/dist/Utils/segmentation/tools/SphereBrushTool.d.ts +127 -0
  23. package/dist/Utils/segmentation/tools/SphereBrushTool.js +489 -0
  24. package/dist/Utils/segmentation/tools/SphereBrushTool.js.map +1 -0
  25. package/dist/Utils/segmentation/tools/ToolHost.d.ts +3 -0
  26. package/dist/Utils/segmentation/tools/ZoomTool.js +6 -1
  27. package/dist/Utils/segmentation/tools/ZoomTool.js.map +1 -1
  28. package/dist/Utils/segmentation/tools/index.d.ts +2 -1
  29. package/dist/Utils/segmentation/tools/index.js +1 -0
  30. package/dist/Utils/segmentation/tools/index.js.map +1 -1
  31. package/dist/bundle.esm.js +670 -53
  32. package/dist/bundle.umd.js +670 -53
  33. package/dist/index.d.ts +1 -1
  34. package/dist/index.js +1 -1
  35. package/dist/types/Utils/segmentation/DrawToolCore.d.ts +6 -0
  36. package/dist/types/Utils/segmentation/NrrdTools.d.ts +2 -0
  37. package/dist/types/Utils/segmentation/core/UndoManager.d.ts +9 -5
  38. package/dist/types/Utils/segmentation/core/index.d.ts +1 -1
  39. package/dist/types/Utils/segmentation/core/types.d.ts +5 -1
  40. package/dist/types/Utils/segmentation/eventRouter/types.d.ts +1 -1
  41. package/dist/types/Utils/segmentation/tools/SphereBrushTool.d.ts +127 -0
  42. package/dist/types/Utils/segmentation/tools/ToolHost.d.ts +3 -0
  43. package/dist/types/Utils/segmentation/tools/index.d.ts +2 -1
  44. package/dist/types/index.d.ts +1 -1
  45. package/package.json +1 -1
@@ -71963,41 +71963,48 @@ void main() {
71963
71963
  setActiveLayer(layer) {
71964
71964
  this.activeLayer = layer;
71965
71965
  }
71966
- /** Push a delta onto the active layer's undo stack and clear the redo stack. */
71966
+ /** Push a single delta onto the active layer's undo stack and clear the redo stack. */
71967
71967
  push(delta) {
71968
+ this.pushGroup([delta]);
71969
+ }
71970
+ /** Push a group of deltas as a single undo unit (e.g. 3D sphere spanning multiple slices). */
71971
+ pushGroup(deltas) {
71968
71972
  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);
71973
+ if (deltas.length === 0)
71974
+ return;
71975
+ const layerId = deltas[0].layerId;
71976
+ const stack = (_a = this.undoStacks.get(layerId)) !== null && _a !== void 0 ? _a : this.undoStacks.get("layer1");
71977
+ stack.push(deltas);
71971
71978
  if (stack.length > MAX_STACK_SIZE) {
71972
71979
  stack.shift();
71973
71980
  }
71974
71981
  // 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");
71982
+ const redoStack = (_b = this.redoStacks.get(layerId)) !== null && _b !== void 0 ? _b : this.redoStacks.get("layer1");
71976
71983
  redoStack.length = 0;
71977
71984
  }
71978
71985
  /**
71979
71986
  * Undo the last operation on the active layer.
71980
- * @returns The delta that was undone, or undefined if nothing to undo.
71987
+ * @returns The delta(s) that were undone, or undefined if nothing to undo.
71981
71988
  */
71982
71989
  undo() {
71983
71990
  const stack = this.undoStacks.get(this.activeLayer);
71984
- const delta = stack.pop();
71985
- if (delta) {
71986
- this.redoStacks.get(this.activeLayer).push(delta);
71991
+ const entry = stack.pop();
71992
+ if (entry) {
71993
+ this.redoStacks.get(this.activeLayer).push(entry);
71987
71994
  }
71988
- return delta;
71995
+ return entry;
71989
71996
  }
71990
71997
  /**
71991
71998
  * Redo the last undone operation on the active layer.
71992
- * @returns The delta that was redone, or undefined if nothing to redo.
71999
+ * @returns The delta(s) that were redone, or undefined if nothing to redo.
71993
72000
  */
71994
72001
  redo() {
71995
72002
  const stack = this.redoStacks.get(this.activeLayer);
71996
- const delta = stack.pop();
71997
- if (delta) {
71998
- this.undoStacks.get(this.activeLayer).push(delta);
72003
+ const entry = stack.pop();
72004
+ if (entry) {
72005
+ this.undoStacks.get(this.activeLayer).push(entry);
71999
72006
  }
72000
- return delta;
72007
+ return entry;
72001
72008
  }
72002
72009
  canUndo() {
72003
72010
  var _a, _b;
@@ -73366,7 +73373,9 @@ void main() {
73366
73373
  // When leaving draw, contrast, or crosshair mode (returning to idle), restore drag mode
73367
73374
  // Do NOT restore if sphere mode is active
73368
73375
  if ((prev === 'draw' || prev === 'contrast' || prev === 'crosshair') && next === 'idle') {
73369
- if (!this.gui_states.mode.sphere) {
73376
+ if (!this.gui_states.mode.sphere
73377
+ && !this.gui_states.mode.sphereBrush
73378
+ && !this.gui_states.mode.sphereEraser) {
73370
73379
  this.configDragMode();
73371
73380
  }
73372
73381
  }
@@ -73905,6 +73914,7 @@ void main() {
73905
73914
  if (ev.key === this.keyboardSettings.draw) {
73906
73915
  this.state.shiftHeld = true;
73907
73916
  // Block draw mode when crosshair or contrast is active (mutual exclusion)
73917
+ // Also block when sphereBrush or sphereEraser is active
73908
73918
  if (DRAWING_TOOLS.has(this.guiTool) && !this.state.ctrlHeld && !this.state.crosshairEnabled) {
73909
73919
  this.setMode('draw');
73910
73920
  }
@@ -74304,10 +74314,15 @@ void main() {
74304
74314
  configMouseZoomWheel() {
74305
74315
  let moveDistance = 1;
74306
74316
  return (e) => {
74307
- var _a;
74317
+ var _a, _b;
74308
74318
  if ((_a = this.ctx.eventRouter) === null || _a === void 0 ? void 0 : _a.isShiftHeld()) {
74309
74319
  return;
74310
74320
  }
74321
+ // Block zoom wheel when sphereBrush/sphereEraser is actively placing (left button held)
74322
+ if ((this.ctx.gui_states.mode.sphereBrush || this.ctx.gui_states.mode.sphereEraser)
74323
+ && ((_b = this.ctx.eventRouter) === null || _b === void 0 ? void 0 : _b.isLeftButtonDown())) {
74324
+ return;
74325
+ }
74311
74326
  e.preventDefault();
74312
74327
  const delta = e.detail ? e.detail > 0 : e.wheelDelta < 0;
74313
74328
  this.ctx.protectedData.isDrawing = true;
@@ -74909,6 +74924,493 @@ void main() {
74909
74924
  }
74910
74925
  }
74911
74926
 
74927
+ /**
74928
+ * SphereBrushTool — 3D sphere brush and eraser on layer MaskVolume.
74929
+ *
74930
+ * Unlike SphereTool (which uses its own isolated sphereMaskVolume),
74931
+ * this tool writes/erases directly in the active layer's shared MaskVolume —
74932
+ * the same volume used by pencil/brush/eraser tools.
74933
+ *
74934
+ * Two operation modes handled by one class:
74935
+ * - **sphereBrush**: Direct click to paint a 3D sphere of the active channel label.
74936
+ * - **sphereEraser**: Shift+click to erase a 3D sphere (only voxels matching active channel).
74937
+ *
74938
+ * Interaction:
74939
+ * 1. Pointer-down records sphere center, switches wheel to radius mode.
74940
+ * 2. Scroll wheel adjusts sphereBrushRadius [1, 50].
74941
+ * 3. Pointer-up writes/erases sphere to volume, captures grouped undo, refreshes canvas.
74942
+ */
74943
+ class SphereBrushTool extends BaseTool {
74944
+ constructor(ctx, callbacks) {
74945
+ super(ctx);
74946
+ /** Recorded sphere center in canvas mm-space */
74947
+ this.centerX = 0;
74948
+ this.centerY = 0;
74949
+ this.centerSlice = 0;
74950
+ /** Whether a sphere placement is in progress */
74951
+ this.active = false;
74952
+ /** Current operation mode for the active placement */
74953
+ this.mode = "brush";
74954
+ /** Cumulative "before" snapshots for drag-erase undo (z-index → slice data) */
74955
+ this.dragBeforeSnapshots = new Map();
74956
+ /** Whether a drag-erase has actually moved (vs. simple click-release) */
74957
+ this.dragMoved = false;
74958
+ this.callbacks = callbacks;
74959
+ }
74960
+ setCallbacks(callbacks) {
74961
+ this.callbacks = callbacks;
74962
+ }
74963
+ // ── Geometry (ported from SphereTool) ──────────────────────────
74964
+ /**
74965
+ * Convert canvas mm-space coordinates to 3D voxel coordinates.
74966
+ */
74967
+ canvasToVoxelCenter(canvasX, canvasY, sliceIndex, axis) {
74968
+ const nrrd = this.ctx.nrrd_states;
74969
+ switch (axis) {
74970
+ case 'z':
74971
+ return {
74972
+ x: canvasX * nrrd.image.nrrd_x_pixel / nrrd.image.nrrd_x_mm,
74973
+ y: canvasY * nrrd.image.nrrd_y_pixel / nrrd.image.nrrd_y_mm,
74974
+ z: sliceIndex,
74975
+ };
74976
+ case 'y':
74977
+ return {
74978
+ x: canvasX * nrrd.image.nrrd_x_pixel / nrrd.image.nrrd_x_mm,
74979
+ y: sliceIndex,
74980
+ z: (nrrd.image.nrrd_z_mm - canvasY) * nrrd.image.nrrd_z_pixel / nrrd.image.nrrd_z_mm,
74981
+ };
74982
+ case 'x':
74983
+ return {
74984
+ x: sliceIndex,
74985
+ y: canvasY * nrrd.image.nrrd_y_pixel / nrrd.image.nrrd_y_mm,
74986
+ z: canvasX * nrrd.image.nrrd_z_pixel / nrrd.image.nrrd_z_mm,
74987
+ };
74988
+ }
74989
+ }
74990
+ /**
74991
+ * Convert mm radius to per-axis voxel radii.
74992
+ */
74993
+ getVoxelRadii(radius) {
74994
+ const nrrd = this.ctx.nrrd_states;
74995
+ return {
74996
+ rx: radius * nrrd.image.nrrd_x_pixel / nrrd.image.nrrd_x_mm,
74997
+ ry: radius * nrrd.image.nrrd_y_pixel / nrrd.image.nrrd_y_mm,
74998
+ rz: radius * nrrd.image.nrrd_z_pixel / nrrd.image.nrrd_z_mm,
74999
+ };
75000
+ }
75001
+ /**
75002
+ * Compute clamped bounding box for an ellipsoid in voxel space.
75003
+ */
75004
+ getBoundingBox(center, radii, dims) {
75005
+ return {
75006
+ minX: Math.max(0, Math.floor(center.x - radii.rx)),
75007
+ maxX: Math.min(dims.width - 1, Math.ceil(center.x + radii.rx)),
75008
+ minY: Math.max(0, Math.floor(center.y - radii.ry)),
75009
+ maxY: Math.min(dims.height - 1, Math.ceil(center.y + radii.ry)),
75010
+ minZ: Math.max(0, Math.floor(center.z - radii.rz)),
75011
+ maxZ: Math.min(dims.depth - 1, Math.ceil(center.z + radii.rz)),
75012
+ };
75013
+ }
75014
+ // ── 3D Volume Write / Erase ────────────────────────────────────
75015
+ /**
75016
+ * Write a 3D sphere of the active channel label into the layer's MaskVolume.
75017
+ */
75018
+ write3DSphereToBrush(vol, center, radii, bb, label) {
75019
+ const { rx, ry, rz } = radii;
75020
+ for (let z = bb.minZ; z <= bb.maxZ; z++) {
75021
+ for (let y = bb.minY; y <= bb.maxY; y++) {
75022
+ for (let x = bb.minX; x <= bb.maxX; x++) {
75023
+ const dx = rx > 0 ? (x - center.x) / rx : 0;
75024
+ const dy = ry > 0 ? (y - center.y) / ry : 0;
75025
+ const dz = rz > 0 ? (z - center.z) / rz : 0;
75026
+ if (dx * dx + dy * dy + dz * dz <= 1.0) {
75027
+ vol.setVoxel(x, y, z, label);
75028
+ }
75029
+ }
75030
+ }
75031
+ }
75032
+ }
75033
+ /**
75034
+ * Erase a 3D sphere from the layer's MaskVolume.
75035
+ * Only clears voxels whose current value equals the active channel label.
75036
+ */
75037
+ erase3DSphereFromVolume(vol, center, radii, bb, label) {
75038
+ const { rx, ry, rz } = radii;
75039
+ for (let z = bb.minZ; z <= bb.maxZ; z++) {
75040
+ for (let y = bb.minY; y <= bb.maxY; y++) {
75041
+ for (let x = bb.minX; x <= bb.maxX; x++) {
75042
+ const dx = rx > 0 ? (x - center.x) / rx : 0;
75043
+ const dy = ry > 0 ? (y - center.y) / ry : 0;
75044
+ const dz = rz > 0 ? (z - center.z) / rz : 0;
75045
+ if (dx * dx + dy * dy + dz * dz <= 1.0) {
75046
+ if (vol.getVoxel(x, y, z) === label) {
75047
+ vol.setVoxel(x, y, z, 0);
75048
+ }
75049
+ }
75050
+ }
75051
+ }
75052
+ }
75053
+ }
75054
+ // ── Undo Capture ───────────────────────────────────────────────
75055
+ /**
75056
+ * Capture slice snapshots for all Z-slices in the bounding box.
75057
+ * Used before and after the sphere operation to build MaskDelta groups.
75058
+ *
75059
+ * We always capture along the Z axis since the 3D sphere spans Z slices.
75060
+ */
75061
+ captureSliceSnapshots(vol, minZ, maxZ) {
75062
+ const snapshots = new Map();
75063
+ for (let z = minZ; z <= maxZ; z++) {
75064
+ try {
75065
+ const { data } = vol.getSliceUint8(z, 'z');
75066
+ snapshots.set(z, data.slice());
75067
+ }
75068
+ catch (_a) {
75069
+ // slice out of bounds — skip
75070
+ }
75071
+ }
75072
+ return snapshots;
75073
+ }
75074
+ /**
75075
+ * Build MaskDelta group from before/after snapshots.
75076
+ */
75077
+ buildUndoGroup(layerId, before, after) {
75078
+ const deltas = [];
75079
+ for (const [sliceIndex, oldSlice] of before) {
75080
+ const newSlice = after.get(sliceIndex);
75081
+ if (!newSlice)
75082
+ continue;
75083
+ // Only include slices that actually changed
75084
+ let changed = false;
75085
+ for (let i = 0; i < oldSlice.length; i++) {
75086
+ if (oldSlice[i] !== newSlice[i]) {
75087
+ changed = true;
75088
+ break;
75089
+ }
75090
+ }
75091
+ if (changed) {
75092
+ deltas.push({ layerId, axis: 'z', sliceIndex, oldSlice, newSlice });
75093
+ }
75094
+ }
75095
+ return deltas;
75096
+ }
75097
+ // ── Preview Rendering ──────────────────────────────────────────
75098
+ /**
75099
+ * Draw a preview circle on the sphere canvas during sphere placement.
75100
+ */
75101
+ drawPreview(mouseX, mouseY, radius, isEraser) {
75102
+ const sphereCanvas = this.ctx.protectedData.canvases.drawingSphereCanvas;
75103
+ const sphereCtx = this.ctx.protectedData.ctxes.drawingSphereCtx;
75104
+ // Clear and resize sphere canvas
75105
+ sphereCanvas.width = this.ctx.protectedData.canvases.originCanvas.width;
75106
+ sphereCanvas.height = this.ctx.protectedData.canvases.originCanvas.height;
75107
+ sphereCtx.beginPath();
75108
+ sphereCtx.arc(mouseX, mouseY, radius, 0, 2 * Math.PI);
75109
+ if (isEraser) {
75110
+ // Eraser preview: dashed outline
75111
+ sphereCtx.strokeStyle = "#ff4444";
75112
+ sphereCtx.lineWidth = 2;
75113
+ sphereCtx.setLineDash([4, 4]);
75114
+ sphereCtx.stroke();
75115
+ sphereCtx.setLineDash([]);
75116
+ }
75117
+ else {
75118
+ // Brush preview: semi-transparent fill with active channel color
75119
+ const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
75120
+ const color = this.getActiveChannelColor(channel);
75121
+ sphereCtx.fillStyle = color;
75122
+ sphereCtx.globalAlpha = 0.5;
75123
+ sphereCtx.fill();
75124
+ sphereCtx.globalAlpha = 1.0;
75125
+ }
75126
+ sphereCtx.closePath();
75127
+ }
75128
+ /**
75129
+ * Clear the sphere preview canvas.
75130
+ */
75131
+ clearPreview() {
75132
+ const sphereCanvas = this.ctx.protectedData.canvases.drawingSphereCanvas;
75133
+ sphereCanvas.width = sphereCanvas.width;
75134
+ }
75135
+ /**
75136
+ * Get the hex color for the active channel from the layer's MaskVolume.
75137
+ */
75138
+ getActiveChannelColor(channel) {
75139
+ var _a;
75140
+ const layer = this.ctx.gui_states.layerChannel.layer;
75141
+ const volumes = this.ctx.protectedData.maskData.volumes;
75142
+ const vol = volumes[layer];
75143
+ if (vol) {
75144
+ const rgba = vol.getChannelColor(channel);
75145
+ const r = rgba.r.toString(16).padStart(2, '0');
75146
+ const g = rgba.g.toString(16).padStart(2, '0');
75147
+ const b = rgba.b.toString(16).padStart(2, '0');
75148
+ return `#${r}${g}${b}`;
75149
+ }
75150
+ return (_a = CHANNEL_HEX_COLORS[channel]) !== null && _a !== void 0 ? _a : "#ff0000";
75151
+ }
75152
+ // ── Wheel Handler ──────────────────────────────────────────────
75153
+ /**
75154
+ * Returns a wheel event handler for adjusting sphereBrushRadius.
75155
+ * Used in both sphereBrush and sphereEraser modes.
75156
+ */
75157
+ configSphereBrushWheel() {
75158
+ return (e) => {
75159
+ e.preventDefault();
75160
+ const sphere = this.ctx.nrrd_states.sphere;
75161
+ if (e.deltaY < 0) {
75162
+ sphere.sphereBrushRadius += 1;
75163
+ }
75164
+ else {
75165
+ sphere.sphereBrushRadius -= 1;
75166
+ }
75167
+ sphere.sphereBrushRadius = Math.max(1, Math.min(sphere.sphereBrushRadius, 50));
75168
+ // Redraw preview
75169
+ if (this.active) {
75170
+ this.drawPreview(this.centerX, this.centerY, sphere.sphereBrushRadius, this.mode === "eraser");
75171
+ }
75172
+ };
75173
+ }
75174
+ // ── Sphere Brush Click/PointerUp ───────────────────────────────
75175
+ /**
75176
+ * Handle pointer-down in sphereBrush mode (direct click, no Shift needed).
75177
+ */
75178
+ onSphereBrushClick(e) {
75179
+ this.centerX = e.offsetX / this.ctx.nrrd_states.view.sizeFactor;
75180
+ this.centerY = e.offsetY / this.ctx.nrrd_states.view.sizeFactor;
75181
+ this.centerSlice = this.ctx.nrrd_states.view.currentSliceIndex;
75182
+ this.active = true;
75183
+ this.mode = "brush";
75184
+ this.drawPreview(this.centerX, this.centerY, this.ctx.nrrd_states.sphere.sphereBrushRadius, false);
75185
+ }
75186
+ /**
75187
+ * Handle pointer-up in sphereBrush mode — write sphere to volume.
75188
+ */
75189
+ onSphereBrushPointerUp() {
75190
+ if (!this.active || this.mode !== "brush")
75191
+ return;
75192
+ this.active = false;
75193
+ const layer = this.ctx.gui_states.layerChannel.layer;
75194
+ const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
75195
+ const axis = this.ctx.protectedData.axis;
75196
+ const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
75197
+ let vol;
75198
+ try {
75199
+ vol = this.callbacks.getVolumeForLayer(layer);
75200
+ }
75201
+ catch (_a) {
75202
+ this.clearPreview();
75203
+ return;
75204
+ }
75205
+ const dims = vol.getDimensions();
75206
+ const center = this.canvasToVoxelCenter(this.centerX, this.centerY, this.centerSlice, axis);
75207
+ const radii = this.getVoxelRadii(radius);
75208
+ const bb = this.getBoundingBox(center, radii, dims);
75209
+ // Capture pre-write snapshots
75210
+ const before = this.captureSliceSnapshots(vol, bb.minZ, bb.maxZ);
75211
+ // Write sphere
75212
+ this.write3DSphereToBrush(vol, center, radii, bb, channel);
75213
+ // Capture post-write snapshots and push undo group
75214
+ const after = this.captureSliceSnapshots(vol, bb.minZ, bb.maxZ);
75215
+ const deltas = this.buildUndoGroup(layer, before, after);
75216
+ if (deltas.length > 0) {
75217
+ this.callbacks.pushUndoGroup(deltas);
75218
+ }
75219
+ // Refresh display and notify backend of ALL changed slices
75220
+ this.refreshDisplay(layer, deltas);
75221
+ this.clearPreview();
75222
+ }
75223
+ // ── Sphere Eraser Click/PointerUp ──────────────────────────────
75224
+ /**
75225
+ * Handle pointer-down in sphereEraser mode.
75226
+ * Initializes drag tracking for cumulative undo.
75227
+ */
75228
+ onSphereEraserClick(e) {
75229
+ this.centerX = e.offsetX / this.ctx.nrrd_states.view.sizeFactor;
75230
+ this.centerY = e.offsetY / this.ctx.nrrd_states.view.sizeFactor;
75231
+ this.centerSlice = this.ctx.nrrd_states.view.currentSliceIndex;
75232
+ this.active = true;
75233
+ this.mode = "eraser";
75234
+ this.dragMoved = false;
75235
+ this.dragBeforeSnapshots.clear();
75236
+ // Capture initial "before" snapshots for the first sphere position
75237
+ const layer = this.ctx.gui_states.layerChannel.layer;
75238
+ const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
75239
+ try {
75240
+ const vol = this.callbacks.getVolumeForLayer(layer);
75241
+ const dims = vol.getDimensions();
75242
+ const axis = this.ctx.protectedData.axis;
75243
+ const center = this.canvasToVoxelCenter(this.centerX, this.centerY, this.centerSlice, axis);
75244
+ const radii = this.getVoxelRadii(radius);
75245
+ const bb = this.getBoundingBox(center, radii, dims);
75246
+ this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
75247
+ }
75248
+ catch (_a) {
75249
+ // Volume not ready
75250
+ }
75251
+ this.drawPreview(this.centerX, this.centerY, radius, true);
75252
+ }
75253
+ /**
75254
+ * Handle pointer-move in sphereEraser mode — drag to continuously erase.
75255
+ */
75256
+ onSphereEraserMove(e) {
75257
+ if (!this.active || this.mode !== "eraser")
75258
+ return;
75259
+ this.dragMoved = true;
75260
+ const mouseX = e.offsetX / this.ctx.nrrd_states.view.sizeFactor;
75261
+ const mouseY = e.offsetY / this.ctx.nrrd_states.view.sizeFactor;
75262
+ const sliceIndex = this.ctx.nrrd_states.view.currentSliceIndex;
75263
+ const layer = this.ctx.gui_states.layerChannel.layer;
75264
+ const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
75265
+ const axis = this.ctx.protectedData.axis;
75266
+ const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
75267
+ let vol;
75268
+ try {
75269
+ vol = this.callbacks.getVolumeForLayer(layer);
75270
+ }
75271
+ catch (_a) {
75272
+ return;
75273
+ }
75274
+ const dims = vol.getDimensions();
75275
+ const center = this.canvasToVoxelCenter(mouseX, mouseY, sliceIndex, axis);
75276
+ const radii = this.getVoxelRadii(radius);
75277
+ const bb = this.getBoundingBox(center, radii, dims);
75278
+ // Expand "before" snapshots to cover any new Z slices
75279
+ this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
75280
+ // Erase sphere at current position
75281
+ this.erase3DSphereFromVolume(vol, center, radii, bb, channel);
75282
+ // Update preview position
75283
+ this.drawPreview(mouseX, mouseY, radius, true);
75284
+ // Refresh display so user sees the erase in real-time
75285
+ this.refreshDisplay(layer);
75286
+ }
75287
+ /**
75288
+ * Handle pointer-up in sphereEraser mode — finalize erase + push undo group.
75289
+ * Works for both click-release and drag-release.
75290
+ */
75291
+ onSphereEraserPointerUp() {
75292
+ if (!this.active || this.mode !== "eraser")
75293
+ return;
75294
+ this.active = false;
75295
+ const layer = this.ctx.gui_states.layerChannel.layer;
75296
+ const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
75297
+ const axis = this.ctx.protectedData.axis;
75298
+ const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
75299
+ let vol;
75300
+ try {
75301
+ vol = this.callbacks.getVolumeForLayer(layer);
75302
+ }
75303
+ catch (_a) {
75304
+ this.clearPreview();
75305
+ this.dragBeforeSnapshots.clear();
75306
+ return;
75307
+ }
75308
+ // If no drag occurred, erase at the click position (original click-release behavior)
75309
+ if (!this.dragMoved) {
75310
+ const dims = vol.getDimensions();
75311
+ const center = this.canvasToVoxelCenter(this.centerX, this.centerY, this.centerSlice, axis);
75312
+ const radii = this.getVoxelRadii(radius);
75313
+ const bb = this.getBoundingBox(center, radii, dims);
75314
+ this.erase3DSphereFromVolume(vol, center, radii, bb, channel);
75315
+ }
75316
+ // Build undo group from cumulative "before" snapshots vs current state
75317
+ const after = this.captureSliceSnapshots(vol, this.dragMinZ(), this.dragMaxZ());
75318
+ const deltas = this.buildUndoGroup(layer, this.dragBeforeSnapshots, after);
75319
+ if (deltas.length > 0) {
75320
+ this.callbacks.pushUndoGroup(deltas);
75321
+ }
75322
+ // Refresh display and notify backend of ALL changed slices
75323
+ this.refreshDisplay(layer, deltas);
75324
+ this.clearPreview();
75325
+ this.dragBeforeSnapshots.clear();
75326
+ }
75327
+ /**
75328
+ * Expand cumulative "before" snapshots to cover z range [minZ, maxZ].
75329
+ * Only captures slices not yet in the map (preserves original state).
75330
+ */
75331
+ expandDragBeforeSnapshots(vol, minZ, maxZ) {
75332
+ for (let z = minZ; z <= maxZ; z++) {
75333
+ if (!this.dragBeforeSnapshots.has(z)) {
75334
+ try {
75335
+ const { data } = vol.getSliceUint8(z, 'z');
75336
+ this.dragBeforeSnapshots.set(z, data.slice());
75337
+ }
75338
+ catch (_a) {
75339
+ // slice out of bounds
75340
+ }
75341
+ }
75342
+ }
75343
+ }
75344
+ /** Get minimum Z index from drag snapshots */
75345
+ dragMinZ() {
75346
+ let min = Infinity;
75347
+ for (const z of this.dragBeforeSnapshots.keys()) {
75348
+ if (z < min)
75349
+ min = z;
75350
+ }
75351
+ return min === Infinity ? 0 : min;
75352
+ }
75353
+ /** Get maximum Z index from drag snapshots */
75354
+ dragMaxZ() {
75355
+ let max = -Infinity;
75356
+ for (const z of this.dragBeforeSnapshots.keys()) {
75357
+ if (z > max)
75358
+ max = z;
75359
+ }
75360
+ return max === -Infinity ? 0 : max;
75361
+ }
75362
+ // ── Post-write Display Refresh ─────────────────────────────────
75363
+ /**
75364
+ * After writing/erasing the sphere in the volume, re-render the current
75365
+ * slice's layer canvas and composite all layers to master.
75366
+ *
75367
+ * @param changedDeltas - If provided, fire onMaskChanged for ALL changed
75368
+ * slices (not just the current view slice) so the backend receives the
75369
+ * full 3D sphere data for NII/GLTF export.
75370
+ */
75371
+ refreshDisplay(layerId, changedDeltas) {
75372
+ // Re-render layer canvas from volume for the current slice
75373
+ const target = this.ctx.protectedData.layerTargets.get(layerId);
75374
+ if (target) {
75375
+ const { ctx, canvas } = target;
75376
+ canvas.width = canvas.width; // clear
75377
+ const buffer = this.callbacks.getOrCreateSliceBuffer(this.ctx.protectedData.axis);
75378
+ if (buffer) {
75379
+ 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);
75380
+ }
75381
+ }
75382
+ // Composite all layers to master canvas
75383
+ this.callbacks.compositeAllLayers();
75384
+ // Fire onMaskChanged for ALL changed slices (not just the current view slice)
75385
+ // This ensures the backend receives the full 3D sphere data for export.
75386
+ try {
75387
+ const vol = this.callbacks.getVolumeForLayer(layerId);
75388
+ const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
75389
+ if (changedDeltas && changedDeltas.length > 0) {
75390
+ // Report every changed Z slice
75391
+ for (const delta of changedDeltas) {
75392
+ const { data: sliceData, width, height } = vol.getSliceUint8(delta.sliceIndex, delta.axis);
75393
+ this.ctx.callbacks.onMaskChanged(sliceData, layerId, channel, delta.sliceIndex, delta.axis, width, height, false);
75394
+ }
75395
+ }
75396
+ else {
75397
+ // Fallback: report only the current slice
75398
+ const axis = this.ctx.protectedData.axis;
75399
+ const sliceIndex = this.ctx.nrrd_states.view.currentSliceIndex;
75400
+ const { data: sliceData, width, height } = vol.getSliceUint8(sliceIndex, axis);
75401
+ this.ctx.callbacks.onMaskChanged(sliceData, layerId, channel, sliceIndex, axis, width, height, false);
75402
+ }
75403
+ }
75404
+ catch (_a) {
75405
+ // Volume not ready
75406
+ }
75407
+ }
75408
+ /** Whether a sphere placement is currently in progress */
75409
+ get isActive() {
75410
+ return this.active;
75411
+ }
75412
+ }
75413
+
74912
75414
  /**
74913
75415
  * DrawToolCore — Tool orchestration and event routing.
74914
75416
  *
@@ -74929,6 +75431,7 @@ void main() {
74929
75431
  handleOnDrawingBrushCricleMove: (ev) => { },
74930
75432
  handleMouseZoomSliceWheel: (e) => { },
74931
75433
  handleSphereWheel: (e) => { },
75434
+ handleSphereBrushWheel: (e) => { },
74932
75435
  };
74933
75436
  this.contrastEventPrameters = {
74934
75437
  move_x: 0,
@@ -75011,6 +75514,16 @@ void main() {
75011
75514
  pushUndoDelta: (delta) => this.undoManager.push(delta),
75012
75515
  getEraserUrls: () => this.eraserUrls,
75013
75516
  });
75517
+ this.sphereBrushTool = new SphereBrushTool(toolCtx, {
75518
+ getVolumeForLayer: (layer) => this.renderer.getVolumeForLayer(layer),
75519
+ compositeAllLayers: () => this.renderer.compositeAllLayers(),
75520
+ pushUndoGroup: (deltas) => this.undoManager.pushGroup(deltas),
75521
+ renderSliceToCanvas: (layer, axis, sliceIndex, buffer, ctx, w, h) => this.renderer.renderSliceToCanvas(layer, axis, sliceIndex, buffer, ctx, w, h),
75522
+ getOrCreateSliceBuffer: (axis) => this.renderer.getOrCreateSliceBuffer(axis),
75523
+ setEmptyCanvasSize: (axis) => this.setEmptyCanvasSize(axis),
75524
+ reloadMasksFromVolume: () => this.reloadMasksFromVolume(),
75525
+ getEraserUrls: () => this.eraserUrls,
75526
+ });
75014
75527
  }
75015
75528
  initDrawToolCore() {
75016
75529
  // Initialize EventRouter for centralized event handling
@@ -75055,12 +75568,28 @@ void main() {
75055
75568
  if ((ev.ctrlKey || ev.metaKey) && (ev.key === redoKey || (ev.shiftKey && ev.key === undoKeyUpper))) {
75056
75569
  this.redoLastPainting();
75057
75570
  }
75571
+ // Handle mouse wheel mode toggle (Ctrl+1 = Scroll:Zoom, Ctrl+2 = Scroll:Slice)
75572
+ if ((ev.ctrlKey || ev.metaKey) && ev.key === '1') {
75573
+ ev.preventDefault();
75574
+ this.state.keyboardSettings.mouseWheel = 'Scroll:Zoom';
75575
+ this.updateMouseWheelEvent();
75576
+ return;
75577
+ }
75578
+ if ((ev.ctrlKey || ev.metaKey) && ev.key === '2') {
75579
+ ev.preventDefault();
75580
+ this.state.keyboardSettings.mouseWheel = 'Scroll:Slice';
75581
+ this.updateMouseWheelEvent();
75582
+ return;
75583
+ }
75058
75584
  // Handle crosshair toggle (allowed in drawing tools AND sphere mode)
75059
75585
  if (ev.key === this.state.keyboardSettings.crosshair) {
75060
75586
  this.eventRouter.toggleCrosshair();
75061
75587
  }
75062
75588
  // Handle draw mode (Shift key) - EventRouter already tracks this
75063
- if (ev.key === this.state.keyboardSettings.draw && !this.state.gui_states.mode.sphere) {
75589
+ if (ev.key === this.state.keyboardSettings.draw
75590
+ && !this.state.gui_states.mode.sphere
75591
+ && !this.state.gui_states.mode.sphereBrush
75592
+ && !this.state.gui_states.mode.sphereEraser) {
75064
75593
  if (this.eventRouter.isCtrlHeld()) {
75065
75594
  return; // Ctrl takes priority
75066
75595
  }
@@ -75112,11 +75641,16 @@ void main() {
75112
75641
  if (this.drawingTool.isActive || this.panTool.isActive) {
75113
75642
  this.drawingPrameters.handleOnDrawingMouseMove(e);
75114
75643
  }
75644
+ // Drag-erase: route move to sphereBrushTool when sphereEraser is active
75645
+ if (this.sphereBrushTool.isActive && this.state.gui_states.mode.sphereEraser) {
75646
+ this.sphereBrushTool.onSphereEraserMove(e);
75647
+ }
75115
75648
  });
75116
75649
  this.eventRouter.setPointerUpHandler((e) => {
75117
75650
  if (this.drawingTool.isActive || this.drawingTool.painting
75118
75651
  || this.panTool.isActive
75119
- || (this.state.gui_states.mode.sphere && this.eventRouter.getMode() !== 'crosshair')) {
75652
+ || (this.state.gui_states.mode.sphere && this.eventRouter.getMode() !== 'crosshair')
75653
+ || this.sphereBrushTool.isActive) {
75120
75654
  this.drawingPrameters.handleOnDrawingMouseUp(e);
75121
75655
  }
75122
75656
  });
@@ -75131,6 +75665,9 @@ void main() {
75131
75665
  else if (this.activeWheelMode === 'sphere') {
75132
75666
  this.drawingPrameters.handleSphereWheel(e);
75133
75667
  }
75668
+ else if (this.activeWheelMode === 'sphereBrush') {
75669
+ this.drawingPrameters.handleSphereBrushWheel(e);
75670
+ }
75134
75671
  });
75135
75672
  // Bind all event listeners
75136
75673
  this.eventRouter.bindAll();
@@ -75194,6 +75731,8 @@ void main() {
75194
75731
  }
75195
75732
  // sphere Wheel
75196
75733
  this.drawingPrameters.handleSphereWheel = this.configMouseSphereWheel();
75734
+ // sphere brush Wheel
75735
+ this.drawingPrameters.handleSphereBrushWheel = this.sphereBrushTool.configSphereBrushWheel();
75197
75736
  // brush circle move — delegated to DrawingTool
75198
75737
  this.drawingPrameters.handleOnDrawingBrushCricleMove =
75199
75738
  this.drawingTool.createBrushTrackingHandler();
@@ -75224,6 +75763,16 @@ void main() {
75224
75763
  e.offsetY / this.state.nrrd_states.view.sizeFactor;
75225
75764
  this.enableCrosshair();
75226
75765
  }
75766
+ else if (this.state.gui_states.mode.sphereBrush && !this.eventRouter.isCrosshairEnabled()) {
75767
+ // SphereBrush: direct click (no Shift needed)
75768
+ this.activeWheelMode = 'sphereBrush';
75769
+ this.sphereBrushTool.onSphereBrushClick(e);
75770
+ }
75771
+ else if (this.state.gui_states.mode.sphereEraser && !this.eventRouter.isCrosshairEnabled()) {
75772
+ // SphereEraser: direct click (Shift handled at mode level)
75773
+ this.activeWheelMode = 'sphereBrush';
75774
+ this.sphereBrushTool.onSphereEraserClick(e);
75775
+ }
75227
75776
  else if (this.state.gui_states.mode.sphere && !this.eventRouter.isCrosshairEnabled()) {
75228
75777
  this.handleSphereClick(e);
75229
75778
  }
@@ -75245,6 +75794,16 @@ void main() {
75245
75794
  this.drawingTool.onPointerUp(e);
75246
75795
  this.activeWheelMode = 'zoom';
75247
75796
  }
75797
+ else if (this.sphereBrushTool.isActive) {
75798
+ // SphereBrush or SphereEraser pointer-up
75799
+ if (this.state.gui_states.mode.sphereBrush) {
75800
+ this.sphereBrushTool.onSphereBrushPointerUp();
75801
+ }
75802
+ else if (this.state.gui_states.mode.sphereEraser) {
75803
+ this.sphereBrushTool.onSphereEraserPointerUp();
75804
+ }
75805
+ this.activeWheelMode = 'zoom';
75806
+ }
75248
75807
  else if (this.state.gui_states.mode.sphere &&
75249
75808
  !this.eventRouter.isCrosshairEnabled()) {
75250
75809
  this.sphereTool.onSpherePointerUp();
@@ -75289,7 +75848,9 @@ void main() {
75289
75848
  }
75290
75849
  }
75291
75850
  this.state.protectedData.ctxes.drawingCtx.drawImage(this.state.protectedData.canvases.drawingCanvasLayerMaster, 0, 0);
75292
- if (this.state.gui_states.mode.sphere) {
75851
+ if (this.state.gui_states.mode.sphere
75852
+ || this.state.gui_states.mode.sphereBrush
75853
+ || this.state.gui_states.mode.sphereEraser) {
75293
75854
  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
75855
  }
75295
75856
  }
@@ -75476,24 +76037,26 @@ void main() {
75476
76037
  * Undo the last drawing operation on the active layer.
75477
76038
  */
75478
76039
  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) {
76040
+ const entry = this.undoManager.undo();
76041
+ if (!entry)
75487
76042
  return;
75488
- }
75489
76043
  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);
76044
+ for (const delta of entry) {
76045
+ try {
76046
+ const vol = this.renderer.getVolumeForLayer(delta.layerId);
76047
+ vol.setSliceUint8(delta.sliceIndex, delta.oldSlice, delta.axis);
76048
+ }
76049
+ catch (_a) {
76050
+ continue;
76051
+ }
76052
+ if (delta.axis === this.state.protectedData.axis && delta.sliceIndex === this.state.nrrd_states.view.currentSliceIndex) {
76053
+ this.applyUndoRedoToCanvas(delta.layerId);
76054
+ }
76055
+ if (!this.state.nrrd_states.flags.loadingMaskData) {
76056
+ const { data: sliceData, width, height } = this.renderer.getVolumeForLayer(delta.layerId)
76057
+ .getSliceUint8(delta.sliceIndex, delta.axis);
76058
+ this.state.annotationCallbacks.onMaskChanged(sliceData, delta.layerId, this.state.gui_states.layerChannel.activeChannel || 1, delta.sliceIndex, delta.axis, width, height, false);
76059
+ }
75497
76060
  }
75498
76061
  this.setIsDrawFalse(1000);
75499
76062
  }
@@ -75501,24 +76064,26 @@ void main() {
75501
76064
  * Redo the last undone operation on the active layer.
75502
76065
  */
75503
76066
  redoLastPainting() {
75504
- const delta = this.undoManager.redo();
75505
- if (!delta)
75506
- return;
75507
- try {
75508
- const vol = this.renderer.getVolumeForLayer(delta.layerId);
75509
- vol.setSliceUint8(delta.sliceIndex, delta.newSlice, delta.axis);
75510
- }
75511
- catch (_a) {
76067
+ const entry = this.undoManager.redo();
76068
+ if (!entry)
75512
76069
  return;
75513
- }
75514
76070
  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);
76071
+ for (const delta of entry) {
76072
+ try {
76073
+ const vol = this.renderer.getVolumeForLayer(delta.layerId);
76074
+ vol.setSliceUint8(delta.sliceIndex, delta.newSlice, delta.axis);
76075
+ }
76076
+ catch (_a) {
76077
+ continue;
76078
+ }
76079
+ if (delta.axis === this.state.protectedData.axis && delta.sliceIndex === this.state.nrrd_states.view.currentSliceIndex) {
76080
+ this.applyUndoRedoToCanvas(delta.layerId);
76081
+ }
76082
+ if (!this.state.nrrd_states.flags.loadingMaskData) {
76083
+ const { data: sliceData, width, height } = this.renderer.getVolumeForLayer(delta.layerId)
76084
+ .getSliceUint8(delta.sliceIndex, delta.axis);
76085
+ this.state.annotationCallbacks.onMaskChanged(sliceData, delta.layerId, this.state.gui_states.layerChannel.activeChannel || 1, delta.sliceIndex, delta.axis, width, height, false);
76086
+ }
75522
76087
  }
75523
76088
  this.setIsDrawFalse(1000);
75524
76089
  }
@@ -75592,6 +76157,14 @@ void main() {
75592
76157
  exitSphereMode() {
75593
76158
  throw new Error("exitSphereMode must be provided by NrrdTools");
75594
76159
  }
76160
+ /** Override in NrrdTools */
76161
+ reloadMasksFromVolume() {
76162
+ throw new Error("reloadMasksFromVolume must be provided by NrrdTools");
76163
+ }
76164
+ /** Override in NrrdTools */
76165
+ updateMouseWheelEvent() {
76166
+ throw new Error("updateMouseWheelEvent must be provided by NrrdTools");
76167
+ }
75595
76168
  }
75596
76169
 
75597
76170
  /**
@@ -75655,6 +76228,7 @@ void main() {
75655
76228
  nippleSphereOrigin: null,
75656
76229
  sphereMaskVolume: null,
75657
76230
  sphereRadius: 5,
76231
+ sphereBrushRadius: 10,
75658
76232
  };
75659
76233
  this.flags = {
75660
76234
  stepClear: 1,
@@ -75676,6 +76250,7 @@ void main() {
75676
76250
  this.sphere.nippleSphereOrigin = null;
75677
76251
  this.sphere.sphereMaskVolume = null;
75678
76252
  this.sphere.sphereRadius = 5;
76253
+ this.sphere.sphereBrushRadius = 10;
75679
76254
  }
75680
76255
  }
75681
76256
 
@@ -75697,6 +76272,8 @@ void main() {
75697
76272
  pencil: true,
75698
76273
  eraser: false,
75699
76274
  sphere: false,
76275
+ sphereBrush: false,
76276
+ sphereEraser: false,
75700
76277
  activeSphereType: "tumour",
75701
76278
  };
75702
76279
  this.drawing = {
@@ -76656,6 +77233,8 @@ void main() {
76656
77233
  this.drawCore.enterSphereMode = () => this.enterSphereMode();
76657
77234
  this.drawCore.exitSphereMode = () => this.exitSphereMode();
76658
77235
  this.drawCore.configMouseSliceWheel = () => this.configMouseSliceWheel();
77236
+ this.drawCore.reloadMasksFromVolume = () => this.reloadMasksFromVolume();
77237
+ this.drawCore.updateMouseWheelEvent = () => this.updateMouseWheelEvent();
76659
77238
  }
76660
77239
  /**
76661
77240
  * A initialise function for nrrd_tools
@@ -76776,13 +77355,18 @@ void main() {
76776
77355
  * Set the current tool mode.
76777
77356
  */
76778
77357
  setMode(mode) {
77358
+ var _a, _b, _c;
76779
77359
  if (!this.guiCallbacks)
76780
77360
  return;
76781
77361
  const prevSphere = this.state.gui_states.mode.sphere;
77362
+ const prevSphereBrush = this.state.gui_states.mode.sphereBrush;
77363
+ const prevSphereEraser = this.state.gui_states.mode.sphereEraser;
76782
77364
  const prevCalculator = this._calculatorActive;
76783
77365
  this.state.gui_states.mode.pencil = false;
76784
77366
  this.state.gui_states.mode.eraser = false;
76785
77367
  this.state.gui_states.mode.sphere = false;
77368
+ this.state.gui_states.mode.sphereBrush = false;
77369
+ this.state.gui_states.mode.sphereEraser = false;
76786
77370
  this._calculatorActive = false;
76787
77371
  switch (mode) {
76788
77372
  case "pencil":
@@ -76803,6 +77387,24 @@ void main() {
76803
77387
  this.state.gui_states.mode.sphere = true;
76804
77388
  this._calculatorActive = true;
76805
77389
  break;
77390
+ case "sphereBrush":
77391
+ this.state.gui_states.mode.sphereBrush = true;
77392
+ this.dragOperator.removeDragMode();
77393
+ (_a = this.drawCore.eventRouter) === null || _a === void 0 ? void 0 : _a.setGuiTool('sphereBrush');
77394
+ break;
77395
+ case "sphereEraser":
77396
+ this.state.gui_states.mode.sphereEraser = true;
77397
+ this.dragOperator.removeDragMode();
77398
+ (_b = this.drawCore.eventRouter) === null || _b === void 0 ? void 0 : _b.setGuiTool('sphereEraser');
77399
+ break;
77400
+ }
77401
+ // Restore drag mode when leaving sphereBrush/sphereEraser
77402
+ if ((prevSphereBrush || prevSphereEraser)
77403
+ && !this.state.gui_states.mode.sphereBrush
77404
+ && !this.state.gui_states.mode.sphereEraser
77405
+ && !this.state.gui_states.mode.sphere) {
77406
+ this.dragOperator.configDragMode();
77407
+ (_c = this.drawCore.eventRouter) === null || _c === void 0 ? void 0 : _c.setGuiTool('pencil');
76806
77408
  }
76807
77409
  if (prevSphere && !this.state.gui_states.mode.sphere) {
76808
77410
  this.guiCallbacks.updateSphereState();
@@ -76815,6 +77417,10 @@ void main() {
76815
77417
  getMode() {
76816
77418
  if (this._calculatorActive)
76817
77419
  return "calculator";
77420
+ if (this.state.gui_states.mode.sphereBrush)
77421
+ return "sphereBrush";
77422
+ if (this.state.gui_states.mode.sphereEraser)
77423
+ return "sphereEraser";
76818
77424
  if (this.state.gui_states.mode.sphere)
76819
77425
  return "sphere";
76820
77426
  if (this.state.gui_states.mode.eraser)
@@ -76840,6 +77446,12 @@ void main() {
76840
77446
  getBrushSize() {
76841
77447
  return this.state.gui_states.drawing.brushAndEraserSize;
76842
77448
  }
77449
+ setSphereBrushRadius(radius) {
77450
+ this.state.nrrd_states.sphere.sphereBrushRadius = Math.max(1, Math.min(50, radius));
77451
+ }
77452
+ getSphereBrushRadius() {
77453
+ return this.state.nrrd_states.sphere.sphereBrushRadius;
77454
+ }
76843
77455
  setWindowHigh(value) {
76844
77456
  var _a;
76845
77457
  this.state.gui_states.viewConfig.readyToUpdate = false;
@@ -77389,10 +78001,15 @@ void main() {
77389
78001
  // ═══════════════════════════════════════════════════════════════════════════
77390
78002
  configMouseSliceWheel() {
77391
78003
  const handleMouseZoomSliceWheelMove = (e) => {
77392
- var _a;
78004
+ var _a, _b;
77393
78005
  if ((_a = this.drawCore.eventRouter) === null || _a === void 0 ? void 0 : _a.isShiftHeld()) {
77394
78006
  return;
77395
78007
  }
78008
+ // Block slice wheel when sphereBrush/sphereEraser is actively placing (left button held)
78009
+ if ((this.state.gui_states.mode.sphereBrush || this.state.gui_states.mode.sphereEraser)
78010
+ && ((_b = this.drawCore.eventRouter) === null || _b === void 0 ? void 0 : _b.isLeftButtonDown())) {
78011
+ return;
78012
+ }
77396
78013
  e.preventDefault();
77397
78014
  if (e.deltaY < 0) {
77398
78015
  this.setSliceMoving(-1);
@@ -77660,7 +78277,7 @@ void main() {
77660
78277
  }
77661
78278
 
77662
78279
  // import * as kiwrious from "copper3d_plugin_heart_k";
77663
- const REVISION = "v3.1.2-beta";
78280
+ const REVISION = "v3.2.0-beta";
77664
78281
  console.log(`%cCopper3D Visualisation %cBeta:${REVISION}`, "padding: 3px;color:white; background:#023047", "padding: 3px;color:white; background:#f50a25");
77665
78282
 
77666
78283
  exports.CHANNEL_COLORS = CHANNEL_COLORS;