copper3d 3.3.2 → 3.4.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 (36) hide show
  1. package/dist/Utils/segmentation/DrawToolCore.js +20 -3
  2. package/dist/Utils/segmentation/DrawToolCore.js.map +1 -1
  3. package/dist/Utils/segmentation/NrrdTools.d.ts +19 -0
  4. package/dist/Utils/segmentation/NrrdTools.js +40 -0
  5. package/dist/Utils/segmentation/NrrdTools.js.map +1 -1
  6. package/dist/Utils/segmentation/RenderingUtils.d.ts +9 -3
  7. package/dist/Utils/segmentation/RenderingUtils.js +35 -15
  8. package/dist/Utils/segmentation/RenderingUtils.js.map +1 -1
  9. package/dist/Utils/segmentation/core/MarchingSquares.d.ts +72 -0
  10. package/dist/Utils/segmentation/core/MarchingSquares.js +169 -0
  11. package/dist/Utils/segmentation/core/MarchingSquares.js.map +1 -0
  12. package/dist/Utils/segmentation/core/index.d.ts +2 -0
  13. package/dist/Utils/segmentation/core/index.js +1 -0
  14. package/dist/Utils/segmentation/core/index.js.map +1 -1
  15. package/dist/Utils/segmentation/eventRouter/EventRouter.js +14 -7
  16. package/dist/Utils/segmentation/eventRouter/EventRouter.js.map +1 -1
  17. package/dist/Utils/segmentation/tools/DrawingTool.d.ts +35 -10
  18. package/dist/Utils/segmentation/tools/DrawingTool.js +247 -36
  19. package/dist/Utils/segmentation/tools/DrawingTool.js.map +1 -1
  20. package/dist/Utils/segmentation/tools/SphereBrushTool.d.ts +83 -6
  21. package/dist/Utils/segmentation/tools/SphereBrushTool.js +283 -68
  22. package/dist/Utils/segmentation/tools/SphereBrushTool.js.map +1 -1
  23. package/dist/Utils/segmentation/tools/ToolHost.d.ts +1 -1
  24. package/dist/bundle.esm.js +808 -130
  25. package/dist/bundle.umd.js +808 -130
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.js +1 -1
  28. package/dist/types/Utils/segmentation/NrrdTools.d.ts +19 -0
  29. package/dist/types/Utils/segmentation/RenderingUtils.d.ts +9 -3
  30. package/dist/types/Utils/segmentation/core/MarchingSquares.d.ts +72 -0
  31. package/dist/types/Utils/segmentation/core/index.d.ts +2 -0
  32. package/dist/types/Utils/segmentation/tools/DrawingTool.d.ts +35 -10
  33. package/dist/types/Utils/segmentation/tools/SphereBrushTool.d.ts +83 -6
  34. package/dist/types/Utils/segmentation/tools/ToolHost.d.ts +1 -1
  35. package/dist/types/index.d.ts +1 -1
  36. package/package.json +1 -1
@@ -72072,6 +72072,175 @@ void main() {
72072
72072
  }
72073
72073
  }
72074
72074
 
72075
+ /**
72076
+ * MarchingSquares — Extract vector contours from a 2D voxel label grid.
72077
+ *
72078
+ * For each label, produces a `Path2D` whose subpaths are small per-cell
72079
+ * polygons. The polygons tile the region occupied by the target label; the
72080
+ * union (via `ctx.fill(path, 'nonzero')`) is the label's silhouette on the
72081
+ * voxel grid.
72082
+ *
72083
+ * Coordinate convention:
72084
+ * - Voxel (i, j) occupies the unit square [i, i+1] × [j, j+1] in render
72085
+ * space, with its center at (i+0.5, j+0.5). This matches ITK-SNAP /
72086
+ * standard imaging viewers and the existing `renderSliceToCanvas`
72087
+ * `scale(scaledW / W, scaledH / H)` mapping.
72088
+ * - Marching squares treats voxels as point samples at their centers:
72089
+ * cell (i, j) has corners at voxel centers (i+0.5, j+0.5),
72090
+ * (i+1.5, j+0.5), (i+1.5, j+1.5), (i+0.5, j+1.5).
72091
+ * - This produces 45°-cut contours (diamonds for isolated voxels, rounded
72092
+ * corners for connected regions) that stay within the voxel-square
72093
+ * bounds, so the rendered silhouette is a slightly-inset version of the
72094
+ * ITK-SNAP silhouette with smooth diagonals.
72095
+ *
72096
+ * Saddle cases (5 and 10) use a fixed convention: the two in-corners are
72097
+ * treated as disconnected within the cell (4-connectivity interpretation).
72098
+ */
72099
+ /**
72100
+ * Per-cell polygon lookup for the 16 marching-squares cases.
72101
+ *
72102
+ * Corner bit layout: TL=8, TR=4, BR=2, BL=1
72103
+ *
72104
+ * Coordinates are within the unit cell, 0 ≤ x ≤ 1, 0 ≤ y ≤ 1,
72105
+ * where (0, 0) is the TL corner of the cell. Add (i, j) to get absolute.
72106
+ *
72107
+ * Each case may emit 0, 1, or 2 polygons (saddles → 2).
72108
+ * Polygon vertices are listed in a CCW order in screen space (y-down),
72109
+ * which corresponds to CW in math coords. Path2D `fill(nonzero)` handles
72110
+ * this consistently.
72111
+ */
72112
+ const CELL_POLYGONS = [
72113
+ /* 0 (empty) */ [],
72114
+ /* 1 (BL) */ [[[0, 0.5], [0.5, 1], [0, 1]]],
72115
+ /* 2 (BR) */ [[[0.5, 1], [1, 0.5], [1, 1]]],
72116
+ /* 3 (BR+BL) */ [[[0, 0.5], [1, 0.5], [1, 1], [0, 1]]],
72117
+ /* 4 (TR) */ [[[0.5, 0], [1, 0], [1, 0.5]]],
72118
+ /* 5 (TR+BL saddle) */ [
72119
+ [[0.5, 0], [1, 0], [1, 0.5]],
72120
+ [[0, 0.5], [0.5, 1], [0, 1]],
72121
+ ],
72122
+ /* 6 (TR+BR) */ [[[0.5, 0], [1, 0], [1, 1], [0.5, 1]]],
72123
+ /* 7 (TR+BR+BL, !TL) */ [[[0.5, 0], [1, 0], [1, 1], [0, 1], [0, 0.5]]],
72124
+ /* 8 (TL) */ [[[0, 0], [0.5, 0], [0, 0.5]]],
72125
+ /* 9 (TL+BL) */ [[[0, 0], [0.5, 0], [0.5, 1], [0, 1]]],
72126
+ /* 10 (TL+BR saddle) */ [
72127
+ [[0, 0], [0.5, 0], [0, 0.5]],
72128
+ [[0.5, 1], [1, 0.5], [1, 1]],
72129
+ ],
72130
+ /* 11 (TL+BR+BL, !TR) */ [[[0, 0], [0.5, 0], [1, 0.5], [1, 1], [0, 1]]],
72131
+ /* 12 (TL+TR) */ [[[0, 0], [1, 0], [1, 0.5], [0, 0.5]]],
72132
+ /* 13 (TL+TR+BL, !BR) */ [[[0, 0], [1, 0], [1, 0.5], [0.5, 1], [0, 1]]],
72133
+ /* 14 (TL+TR+BR, !BL) */ [[[0, 0], [1, 0], [1, 1], [0.5, 1], [0, 0.5]]],
72134
+ /* 15 (full) */ [[[0, 0], [1, 0], [1, 1], [0, 1]]],
72135
+ ];
72136
+ /**
72137
+ * Emit all per-cell polygons covering the voxels where `labels === targetLabel`.
72138
+ * Pure geometry function — no Canvas dependency. Consumers can build a Path2D
72139
+ * (see {@link extractLabelContours}) or walk the vertex arrays directly
72140
+ * (tests, export, stroke rendering).
72141
+ */
72142
+ function extractLabelPolygons(labels, width, height, targetLabel, stride = 1, channelOffset = 0, bbox) {
72143
+ var _a, _b, _c, _d;
72144
+ const out = [];
72145
+ const cx0 = ((_a = bbox === null || bbox === void 0 ? void 0 : bbox.x0) !== null && _a !== void 0 ? _a : 0) - 1;
72146
+ const cy0 = ((_b = bbox === null || bbox === void 0 ? void 0 : bbox.y0) !== null && _b !== void 0 ? _b : 0) - 1;
72147
+ const cx1 = (_c = bbox === null || bbox === void 0 ? void 0 : bbox.x1) !== null && _c !== void 0 ? _c : width;
72148
+ const cy1 = (_d = bbox === null || bbox === void 0 ? void 0 : bbox.y1) !== null && _d !== void 0 ? _d : height;
72149
+ const i0 = Math.max(-1, cx0);
72150
+ const j0 = Math.max(-1, cy0);
72151
+ const i1 = Math.min(width, cx1);
72152
+ const j1 = Math.min(height, cy1);
72153
+ const sample = (x, y) => {
72154
+ if (x < 0 || x >= width || y < 0 || y >= height)
72155
+ return false;
72156
+ return labels[(y * width + x) * stride + channelOffset] === targetLabel;
72157
+ };
72158
+ // Shift output by +0.5 so that marching-squares samples (voxel positions)
72159
+ // map to voxel-square CENTERS in render space. Voxel (i, j) occupies
72160
+ // render square [i, i+1] × [j, j+1]; its center is (i+0.5, j+0.5).
72161
+ const SHIFT = 0.5;
72162
+ for (let j = j0; j < j1; j++) {
72163
+ for (let i = i0; i < i1; i++) {
72164
+ const tl = sample(i, j);
72165
+ const tr = sample(i + 1, j);
72166
+ const br = sample(i + 1, j + 1);
72167
+ const bl = sample(i, j + 1);
72168
+ const code = (tl ? 8 : 0) |
72169
+ (tr ? 4 : 0) |
72170
+ (br ? 2 : 0) |
72171
+ (bl ? 1 : 0);
72172
+ if (code === 0)
72173
+ continue;
72174
+ const polygons = CELL_POLYGONS[code];
72175
+ for (let p = 0; p < polygons.length; p++) {
72176
+ const poly = polygons[p];
72177
+ const abs = new Array(poly.length);
72178
+ for (let k = 0; k < poly.length; k++) {
72179
+ abs[k] = [i + poly[k][0] + SHIFT, j + poly[k][1] + SHIFT];
72180
+ }
72181
+ out.push(abs);
72182
+ }
72183
+ }
72184
+ }
72185
+ return out;
72186
+ }
72187
+ /**
72188
+ * Extract a `Path2D` covering all voxels equal to `targetLabel`.
72189
+ *
72190
+ * @param labels Flat label array. Addressed as
72191
+ * `labels[(y * width + x) * stride + channelOffset]`.
72192
+ * @param width Grid width in voxels.
72193
+ * @param height Grid height in voxels.
72194
+ * @param targetLabel Label value to extract (1..255; 0 is background).
72195
+ * @param stride Bytes per voxel (default 1). Use MaskVolume.numChannels
72196
+ * when reading interleaved slices.
72197
+ * @param channelOffset Offset within each voxel's channels to sample.
72198
+ * Default 0 (first channel = label id).
72199
+ * @param bbox Optional voxel-space bbox to limit extraction.
72200
+ * Cells in a 1-voxel halo around the bbox are still
72201
+ * visited so the contour closes correctly at the bbox edges.
72202
+ */
72203
+ function extractLabelContours(labels, width, height, targetLabel, stride = 1, channelOffset = 0, bbox) {
72204
+ const polygons = extractLabelPolygons(labels, width, height, targetLabel, stride, channelOffset, bbox);
72205
+ const path = new Path2D();
72206
+ for (let p = 0; p < polygons.length; p++) {
72207
+ const poly = polygons[p];
72208
+ path.moveTo(poly[0][0], poly[0][1]);
72209
+ for (let k = 1; k < poly.length; k++) {
72210
+ path.lineTo(poly[k][0], poly[k][1]);
72211
+ }
72212
+ path.closePath();
72213
+ }
72214
+ return path;
72215
+ }
72216
+ /**
72217
+ * Detect the distinct non-zero labels present in a slice region.
72218
+ *
72219
+ * @param labels Flat label array (see {@link extractLabelContours}).
72220
+ * @param width Grid width.
72221
+ * @param height Grid height.
72222
+ * @param stride Bytes per voxel.
72223
+ * @param channelOffset Offset within each voxel.
72224
+ * @param bbox Optional region to scan (defaults to full grid).
72225
+ * @returns Sorted array of distinct label values (0 omitted).
72226
+ */
72227
+ function findLabelsInSlice(labels, width, height, stride = 1, channelOffset = 0, bbox) {
72228
+ var _a, _b, _c, _d;
72229
+ const x0 = (_a = bbox === null || bbox === void 0 ? void 0 : bbox.x0) !== null && _a !== void 0 ? _a : 0;
72230
+ const y0 = (_b = bbox === null || bbox === void 0 ? void 0 : bbox.y0) !== null && _b !== void 0 ? _b : 0;
72231
+ const x1 = (_c = bbox === null || bbox === void 0 ? void 0 : bbox.x1) !== null && _c !== void 0 ? _c : width;
72232
+ const y1 = (_d = bbox === null || bbox === void 0 ? void 0 : bbox.y1) !== null && _d !== void 0 ? _d : height;
72233
+ const seen = new Set();
72234
+ for (let y = y0; y < y1; y++) {
72235
+ for (let x = x0; x < x1; x++) {
72236
+ const v = labels[(y * width + x) * stride + channelOffset];
72237
+ if (v !== 0)
72238
+ seen.add(v);
72239
+ }
72240
+ }
72241
+ return Array.from(seen).sort((a, b) => a - b);
72242
+ }
72243
+
72075
72244
  /**
72076
72245
  * BaseTool - Abstract base for extracted DrawToolCore tools
72077
72246
  *
@@ -73559,35 +73728,54 @@ void main() {
73559
73728
  }
73560
73729
  }
73561
73730
  /**
73562
- * Render a layer's slice into a reusable buffer and draw to the target canvas.
73731
+ * Render a layer's slice onto the target canvas as vector contours.
73563
73732
  *
73564
- * Uses MaskVolume.renderLabelSliceInto() for zero-allocation rendering.
73733
+ * Uses marching-squares to extract voxel-truthful Path2D contours per
73734
+ * label, then `ctx.fill()` them at the display canvas resolution. This
73735
+ * eliminates the bilinear-upscale blur and zoom-dependent shape drift
73736
+ * that plagued the old putImageData → drawImage pipeline.
73737
+ *
73738
+ * The `buffer` parameter is kept for backward-compatible signature but
73739
+ * is no longer used on this path — callers may pass any valid ImageData.
73565
73740
  */
73566
- renderSliceToCanvas(layer, axis, sliceIndex, buffer, targetCtx, scaledWidth, scaledHeight) {
73741
+ renderSliceToCanvas(layer, axis, sliceIndex, _buffer, targetCtx, scaledWidth, scaledHeight) {
73567
73742
  try {
73568
73743
  const volume = this.getVolumeForLayer(layer);
73569
73744
  if (!volume)
73570
73745
  return;
73571
- // Get channel visibility for this layer
73572
73746
  const channelVis = this.state.gui_states.layerChannel.channelVisibility[layer];
73573
- // Render label slice at full alpha — globalAlpha applied during compositeAllLayers
73574
- volume.renderLabelSliceInto(sliceIndex, axis, buffer, channelVis, 1.0);
73575
- this.setEmptyCanvasSize(axis);
73576
- this.state.protectedData.ctxes.emptyCtx.putImageData(buffer, 0, 0);
73747
+ const slice = volume.getSliceUint8(sliceIndex, axis);
73748
+ const W = slice.width;
73749
+ const H = slice.height;
73750
+ const stride = volume.getChannels();
73751
+ const labels = findLabelsInSlice(slice.data, W, H, stride, 0);
73752
+ if (labels.length === 0)
73753
+ return;
73754
+ targetCtx.save();
73755
+ // Vector fill — imageSmoothingEnabled is irrelevant here, but keep
73756
+ // it off to match the rest of the pipeline.
73577
73757
  targetCtx.imageSmoothingEnabled = false;
73578
- // Coronal (axis='y') Z-flip: vertically flip the rendered mask to match
73579
- // the Z-flip applied during the write path (syncLayerSliceData).
73758
+ // Coronal (axis='y') Z-flip: mirrors the flip applied by the write
73759
+ // path (syncLayerSliceData). Apply BEFORE the voxel→display scale
73760
+ // so the flip operates in display coordinates.
73580
73761
  if (axis === 'y') {
73581
- targetCtx.save();
73582
73762
  targetCtx.scale(1, -1);
73583
73763
  targetCtx.translate(0, -scaledHeight);
73584
73764
  }
73585
- targetCtx.drawImage(this.state.protectedData.canvases.emptyCanvas, 0, 0, scaledWidth, scaledHeight);
73586
- if (axis === 'y') {
73587
- targetCtx.restore();
73765
+ // Voxel coord (x ∈ [0, W], y ∈ [0, H]) → display coord.
73766
+ targetCtx.scale(scaledWidth / W, scaledHeight / H);
73767
+ for (const lbl of labels) {
73768
+ if (channelVis && channelVis[lbl] === false)
73769
+ continue;
73770
+ const color = volume.getChannelColor(lbl);
73771
+ const path = extractLabelContours(slice.data, W, H, lbl, stride, 0);
73772
+ targetCtx.fillStyle =
73773
+ `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a / 255})`;
73774
+ targetCtx.fill(path, 'nonzero');
73588
73775
  }
73776
+ targetCtx.restore();
73589
73777
  }
73590
- catch (err) {
73778
+ catch (_a) {
73591
73779
  // Slice out of bounds or volume not ready — skip silently
73592
73780
  }
73593
73781
  }
@@ -73672,6 +73860,11 @@ void main() {
73672
73860
  * Drawing tools that can be used with Shift+drag
73673
73861
  */
73674
73862
  const DRAWING_TOOLS = new Set(['pencil', 'brush', 'eraser']);
73863
+ /**
73864
+ * Sphere-family tools (single-placement sphere + brush/eraser variants).
73865
+ * All three participate in the same crosshair / contrast mutual-exclusion rules.
73866
+ */
73867
+ const SPHERE_TOOLS = new Set(['sphere', 'sphereBrush', 'sphereEraser']);
73675
73868
  class EventRouter {
73676
73869
  constructor(config) {
73677
73870
  // === State ===
@@ -73802,8 +73995,8 @@ void main() {
73802
73995
  */
73803
73996
  setGuiTool(tool) {
73804
73997
  this.guiTool = tool;
73805
- // When entering sphere mode, keep crosshair if active, otherwise idle
73806
- if (tool === 'sphere') {
73998
+ // When entering any sphere-family tool, keep crosshair if active, otherwise idle
73999
+ if (SPHERE_TOOLS.has(tool)) {
73807
74000
  if (!this.state.crosshairEnabled) {
73808
74001
  this.setMode('idle');
73809
74002
  }
@@ -73815,10 +74008,12 @@ void main() {
73815
74008
  * Blocked when draw or contrast mode is active, or left button is held (mutual exclusion).
73816
74009
  */
73817
74010
  toggleCrosshair() {
73818
- // Allow crosshair in drawing tools and sphere mode
73819
- if (!DRAWING_TOOLS.has(this.guiTool) && this.guiTool !== 'sphere')
74011
+ // Allow crosshair in drawing tools and all sphere-family tools (sphere / sphereBrush / sphereEraser)
74012
+ if (!DRAWING_TOOLS.has(this.guiTool) && !SPHERE_TOOLS.has(this.guiTool))
73820
74013
  return;
73821
- // Block crosshair activation during draw, contrast, or while left button held
74014
+ // Block crosshair activation during draw, contrast, or while left button held.
74015
+ // The leftButtonDown guard also enforces "once a sphere preview is on screen,
74016
+ // S is ignored until mouseup" for sphereBrush/sphereEraser.
73822
74017
  if (this.state.shiftHeld || this.state.leftButtonDown || this.mode === 'draw' || this.mode === 'contrast')
73823
74018
  return;
73824
74019
  this.state.crosshairEnabled = !this.state.crosshairEnabled;
@@ -73945,9 +74140,9 @@ void main() {
73945
74140
  }
73946
74141
  }
73947
74142
  if (this.contrastEnabled && this.keyboardSettings.contrast.includes(ev.key)) {
73948
- // Block contrast state when crosshair, draw, or sphere is active (mutual exclusion)
74143
+ // Block contrast state when crosshair, draw, or any sphere-family tool is active (mutual exclusion)
73949
74144
  if (!this.state.crosshairEnabled && this.mode !== 'draw'
73950
- && this.guiTool !== 'sphere') {
74145
+ && !SPHERE_TOOLS.has(this.guiTool)) {
73951
74146
  this.state.ctrlHeld = true;
73952
74147
  }
73953
74148
  }
@@ -74571,6 +74766,10 @@ void main() {
74571
74766
  * Extracted from DrawToolCore.paintOnCanvas() closure (Phase 3).
74572
74767
  * Handles left-click drawing operations: pencil stroke + fill, brush strokes,
74573
74768
  * eraser, and undo snapshot capture/push.
74769
+ *
74770
+ * Phase B: Brush mode now writes voxels directly during mousemove and
74771
+ * re-renders via marching squares, eliminating the visual "snap" between
74772
+ * the smooth canvas stroke and the voxel-based post-release render.
74574
74773
  */
74575
74774
  class DrawingTool extends BaseTool {
74576
74775
  constructor(ctx, callbacks) {
@@ -74587,6 +74786,8 @@ void main() {
74587
74786
  this.preDrawSlice = null;
74588
74787
  this.preDrawAxis = "z";
74589
74788
  this.preDrawSliceIndex = 0;
74789
+ /** Previous voxel-space position for Bresenham interpolation (brush mode) */
74790
+ this.prevVoxel = null;
74590
74791
  this.callbacks = callbacks;
74591
74792
  }
74592
74793
  /** Whether a draw operation is currently active */
@@ -74606,6 +74807,7 @@ void main() {
74606
74807
  this.isPainting = false;
74607
74808
  this.drawingLines = [];
74608
74809
  this.clearArcFn = clearArcFn;
74810
+ this.prevVoxel = null;
74609
74811
  }
74610
74812
  /**
74611
74813
  * Called on left-click pointerdown in draw mode.
@@ -74633,6 +74835,22 @@ void main() {
74633
74835
  this.ctx.nrrd_states.interaction.drawStartPos.y = e.offsetY;
74634
74836
  // Capture pre-draw slice snapshot for undo
74635
74837
  this.capturePreDrawSnapshot();
74838
+ // Brush mode: initialize voxel tracking and write first dot
74839
+ if (!this.ctx.gui_states.mode.pencil && !this.ctx.gui_states.mode.eraser) {
74840
+ const voxel = this.canvasToVoxel3D(e.offsetX, e.offsetY);
74841
+ this.prevVoxel = voxel;
74842
+ try {
74843
+ const vol = this.callbacks.getVolumeForLayer(this.ctx.gui_states.layerChannel.layer);
74844
+ const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
74845
+ const axis = this.ctx.protectedData.axis;
74846
+ const { rH, rV } = this.getVoxelBrushRadius();
74847
+ this.paintVoxelEllipse(vol, voxel, rH, rV, channel, axis);
74848
+ this.refreshLayerFromVolume();
74849
+ }
74850
+ catch (_a) {
74851
+ // Volume not ready
74852
+ }
74853
+ }
74636
74854
  }
74637
74855
  /**
74638
74856
  * Called on pointermove during an active drawing operation.
@@ -74646,10 +74864,16 @@ void main() {
74646
74864
  this.ctx.nrrd_states.flags.stepClear = 1;
74647
74865
  (_a = this.clearArcFn) === null || _a === void 0 ? void 0 : _a.call(this, e.offsetX, e.offsetY, this.ctx.gui_states.drawing.brushAndEraserSize);
74648
74866
  }
74649
- else {
74867
+ else if (this.ctx.gui_states.mode.pencil) {
74868
+ // Pencil mode: accumulate points and draw visual lines on canvas
74650
74869
  this.drawingLines.push({ x: e.offsetX, y: e.offsetY });
74651
74870
  this.paintOnCanvasLayer(e.offsetX, e.offsetY);
74652
74871
  }
74872
+ else {
74873
+ // Brush mode: write voxels directly + re-render via marching squares
74874
+ this.drawingLines.push({ x: e.offsetX, y: e.offsetY });
74875
+ this.paintBrushVoxelMove(e.offsetX, e.offsetY);
74876
+ }
74653
74877
  }
74654
74878
  }
74655
74879
  /**
@@ -74662,11 +74886,9 @@ void main() {
74662
74886
  ctx.closePath();
74663
74887
  if (!this.ctx.gui_states.mode.eraser) {
74664
74888
  if (this.ctx.gui_states.mode.pencil) {
74665
- // Clear only the current layer canvas (NOT master)
74889
+ // Pencil mode: fill polygon then bake to voxels
74666
74890
  canvas.width = canvas.width;
74667
- // Redraw previous layer data from volume
74668
74891
  this.redrawPreviousImageToLayerCtx(ctx);
74669
- // Draw new pencil strokes on current layer canvas
74670
74892
  ctx.beginPath();
74671
74893
  ctx.moveTo(this.drawingLines[0].x, this.drawingLines[0].y);
74672
74894
  for (let i = 1; i < this.drawingLines.length; i++) {
@@ -74676,12 +74898,26 @@ void main() {
74676
74898
  ctx.lineWidth = 1;
74677
74899
  ctx.fillStyle = this.ctx.gui_states.drawing.fillColor;
74678
74900
  ctx.fill();
74679
- // Composite ALL layers to master (not just current layer)
74680
74901
  this.callbacks.compositeAllLayers();
74902
+ // Pencil still needs canvas→voxel bake
74903
+ this.callbacks.syncLayerSliceData(this.ctx.nrrd_states.view.currentSliceIndex, this.ctx.gui_states.layerChannel.layer);
74904
+ // Re-render from voxels to eliminate pencil snap
74905
+ this.refreshLayerFromVolume();
74681
74906
  }
74907
+ else {
74908
+ // Brush mode: voxels already written during mousemove
74909
+ // Just re-render final state and composite
74910
+ this.refreshLayerFromVolume();
74911
+ }
74912
+ }
74913
+ else {
74914
+ // Eraser mode: still needs canvas→voxel bake
74915
+ this.callbacks.syncLayerSliceData(this.ctx.nrrd_states.view.currentSliceIndex, this.ctx.gui_states.layerChannel.layer);
74916
+ // Re-render from voxels for eraser too
74917
+ this.refreshLayerFromVolume();
74682
74918
  }
74683
- this.callbacks.syncLayerSliceData(this.ctx.nrrd_states.view.currentSliceIndex, this.ctx.gui_states.layerChannel.layer);
74684
74919
  this.isPainting = false;
74920
+ this.prevVoxel = null;
74685
74921
  // Push undo delta
74686
74922
  this.pushUndoDelta();
74687
74923
  }
@@ -74692,6 +74928,7 @@ void main() {
74692
74928
  */
74693
74929
  onPointerLeave() {
74694
74930
  this.isPainting = false;
74931
+ this.prevVoxel = null;
74695
74932
  if (!this.leftClicked)
74696
74933
  return false;
74697
74934
  this.leftClicked = false;
@@ -74702,11 +74939,6 @@ void main() {
74702
74939
  /**
74703
74940
  * Create a self-managing mouseover/mouseout/mousemove handler
74704
74941
  * that tracks brush hover position for the preview circle.
74705
- *
74706
- * The returned function should be registered on drawingCanvas for
74707
- * "mouseover" and "mouseout" events. It adds/removes a "mousemove"
74708
- * listener on itself to keep mouseOverX/Y up-to-date while the
74709
- * cursor is inside the canvas.
74710
74942
  */
74711
74943
  createBrushTrackingHandler() {
74712
74944
  const handler = (e) => {
@@ -74730,8 +74962,6 @@ void main() {
74730
74962
  }
74731
74963
  /**
74732
74964
  * Render brush circle preview on the drawing context.
74733
- * Called from the start() render loop when in draw mode and not
74734
- * actively painting. Skipped in pencil/eraser mode.
74735
74965
  */
74736
74966
  renderBrushPreview(ctx, width, height) {
74737
74967
  if (this.ctx.gui_states.mode.pencil ||
@@ -74746,6 +74976,195 @@ void main() {
74746
74976
  ctx.strokeStyle = this.ctx.gui_states.drawing.brushColor;
74747
74977
  ctx.stroke();
74748
74978
  }
74979
+ // ── Brush mode: direct voxel write ────────────────────────────
74980
+ /**
74981
+ * Convert display (zoomed) coordinates to 3D voxel coordinates.
74982
+ * Same logic as SphereBrushTool.canvasToVoxelCenter.
74983
+ */
74984
+ canvasToVoxel3D(displayX, displayY) {
74985
+ const nrrd = this.ctx.nrrd_states;
74986
+ const axis = this.ctx.protectedData.axis;
74987
+ const cw = nrrd.view.changedWidth;
74988
+ const ch = nrrd.view.changedHeight;
74989
+ const sliceIndex = nrrd.view.currentSliceIndex;
74990
+ switch (axis) {
74991
+ case 'z':
74992
+ return {
74993
+ x: displayX * nrrd.image.nrrd_x_pixel / cw,
74994
+ y: displayY * nrrd.image.nrrd_y_pixel / ch,
74995
+ z: sliceIndex,
74996
+ };
74997
+ case 'y':
74998
+ return {
74999
+ x: displayX * nrrd.image.nrrd_x_pixel / cw,
75000
+ y: sliceIndex,
75001
+ z: (ch - displayY) * nrrd.image.nrrd_z_pixel / ch,
75002
+ };
75003
+ case 'x':
75004
+ return {
75005
+ x: sliceIndex,
75006
+ y: displayY * nrrd.image.nrrd_y_pixel / ch,
75007
+ z: displayX * nrrd.image.nrrd_z_pixel / cw,
75008
+ };
75009
+ }
75010
+ }
75011
+ /**
75012
+ * Get voxel-space brush radius for visible axes.
75013
+ * Converts display-pixel brush size to voxel units.
75014
+ */
75015
+ getVoxelBrushRadius() {
75016
+ const nrrd = this.ctx.nrrd_states;
75017
+ const axis = this.ctx.protectedData.axis;
75018
+ const cw = nrrd.view.changedWidth;
75019
+ const ch = nrrd.view.changedHeight;
75020
+ const displayRadius = this.ctx.gui_states.drawing.brushAndEraserSize / 2;
75021
+ switch (axis) {
75022
+ case 'z':
75023
+ return {
75024
+ rH: displayRadius * nrrd.image.nrrd_x_pixel / cw,
75025
+ rV: displayRadius * nrrd.image.nrrd_y_pixel / ch,
75026
+ };
75027
+ case 'y':
75028
+ return {
75029
+ rH: displayRadius * nrrd.image.nrrd_x_pixel / cw,
75030
+ rV: displayRadius * nrrd.image.nrrd_z_pixel / ch,
75031
+ };
75032
+ case 'x':
75033
+ return {
75034
+ rH: displayRadius * nrrd.image.nrrd_z_pixel / cw,
75035
+ rV: displayRadius * nrrd.image.nrrd_y_pixel / ch,
75036
+ };
75037
+ }
75038
+ }
75039
+ /**
75040
+ * Paint a 2D ellipse of voxels on the current slice.
75041
+ * Only the two visible axes are iterated; the slice-direction axis is fixed.
75042
+ */
75043
+ paintVoxelEllipse(vol, center, rH, rV, label, axis) {
75044
+ const dims = vol.getDimensions();
75045
+ switch (axis) {
75046
+ case 'z': {
75047
+ const minX = Math.max(0, Math.floor(center.x - rH));
75048
+ const maxX = Math.min(dims.width - 1, Math.ceil(center.x + rH));
75049
+ const minY = Math.max(0, Math.floor(center.y - rV));
75050
+ const maxY = Math.min(dims.height - 1, Math.ceil(center.y + rV));
75051
+ const z = Math.round(center.z);
75052
+ for (let y = minY; y <= maxY; y++) {
75053
+ for (let x = minX; x <= maxX; x++) {
75054
+ const dx = rH > 0 ? (x - center.x) / rH : 0;
75055
+ const dy = rV > 0 ? (y - center.y) / rV : 0;
75056
+ if (dx * dx + dy * dy <= 1.0) {
75057
+ vol.setVoxel(x, y, z, label);
75058
+ }
75059
+ }
75060
+ }
75061
+ break;
75062
+ }
75063
+ case 'y': {
75064
+ const minX = Math.max(0, Math.floor(center.x - rH));
75065
+ const maxX = Math.min(dims.width - 1, Math.ceil(center.x + rH));
75066
+ const minZ = Math.max(0, Math.floor(center.z - rV));
75067
+ const maxZ = Math.min(dims.depth - 1, Math.ceil(center.z + rV));
75068
+ const y = Math.round(center.y);
75069
+ for (let z = minZ; z <= maxZ; z++) {
75070
+ for (let x = minX; x <= maxX; x++) {
75071
+ const dx = rH > 0 ? (x - center.x) / rH : 0;
75072
+ const dz = rV > 0 ? (z - center.z) / rV : 0;
75073
+ if (dx * dx + dz * dz <= 1.0) {
75074
+ vol.setVoxel(x, y, z, label);
75075
+ }
75076
+ }
75077
+ }
75078
+ break;
75079
+ }
75080
+ case 'x': {
75081
+ const minZ = Math.max(0, Math.floor(center.z - rH));
75082
+ const maxZ = Math.min(dims.depth - 1, Math.ceil(center.z + rH));
75083
+ const minY = Math.max(0, Math.floor(center.y - rV));
75084
+ const maxY = Math.min(dims.height - 1, Math.ceil(center.y + rV));
75085
+ const x = Math.round(center.x);
75086
+ for (let yy = minY; yy <= maxY; yy++) {
75087
+ for (let zz = minZ; zz <= maxZ; zz++) {
75088
+ const dz = rH > 0 ? (zz - center.z) / rH : 0;
75089
+ const dy = rV > 0 ? (yy - center.y) / rV : 0;
75090
+ if (dz * dz + dy * dy <= 1.0) {
75091
+ vol.setVoxel(x, yy, zz, label);
75092
+ }
75093
+ }
75094
+ }
75095
+ break;
75096
+ }
75097
+ }
75098
+ }
75099
+ /**
75100
+ * Brush mode mousemove: write voxels along stroke segment, then re-render.
75101
+ * Uses linear interpolation between prev and current positions to ensure
75102
+ * no gaps when the mouse moves fast.
75103
+ */
75104
+ paintBrushVoxelMove(displayX, displayY) {
75105
+ try {
75106
+ const vol = this.callbacks.getVolumeForLayer(this.ctx.gui_states.layerChannel.layer);
75107
+ const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
75108
+ const axis = this.ctx.protectedData.axis;
75109
+ const { rH, rV } = this.getVoxelBrushRadius();
75110
+ const current = this.canvasToVoxel3D(displayX, displayY);
75111
+ if (this.prevVoxel) {
75112
+ // Interpolate from prev to current to fill gaps
75113
+ const prev = this.prevVoxel;
75114
+ let steps;
75115
+ switch (axis) {
75116
+ case 'z':
75117
+ steps = Math.max(Math.abs(current.x - prev.x), Math.abs(current.y - prev.y));
75118
+ break;
75119
+ case 'y':
75120
+ steps = Math.max(Math.abs(current.x - prev.x), Math.abs(current.z - prev.z));
75121
+ break;
75122
+ case 'x':
75123
+ steps = Math.max(Math.abs(current.z - prev.z), Math.abs(current.y - prev.y));
75124
+ break;
75125
+ }
75126
+ steps = Math.max(1, Math.ceil(steps));
75127
+ for (let i = 0; i <= steps; i++) {
75128
+ const t = steps === 0 ? 0 : i / steps;
75129
+ const pt = {
75130
+ x: prev.x + (current.x - prev.x) * t,
75131
+ y: prev.y + (current.y - prev.y) * t,
75132
+ z: prev.z + (current.z - prev.z) * t,
75133
+ };
75134
+ this.paintVoxelEllipse(vol, pt, rH, rV, channel, axis);
75135
+ }
75136
+ }
75137
+ else {
75138
+ this.paintVoxelEllipse(vol, current, rH, rV, channel, axis);
75139
+ }
75140
+ this.prevVoxel = current;
75141
+ // Re-render the layer canvas from volume (marching squares)
75142
+ this.refreshLayerFromVolume();
75143
+ }
75144
+ catch (_a) {
75145
+ // Volume not ready — fall back to canvas drawing
75146
+ this.paintOnCanvasLayer(displayX, displayY);
75147
+ }
75148
+ }
75149
+ /**
75150
+ * Re-render the active layer's canvas from MaskVolume via marching squares,
75151
+ * then composite all layers to master.
75152
+ */
75153
+ refreshLayerFromVolume() {
75154
+ const layer = this.ctx.gui_states.layerChannel.layer;
75155
+ const target = this.ctx.protectedData.layerTargets.get(layer);
75156
+ if (!target)
75157
+ return;
75158
+ const { ctx, canvas } = target;
75159
+ canvas.width = canvas.width; // clear
75160
+ const axis = this.ctx.protectedData.axis;
75161
+ const buffer = this.callbacks.getOrCreateSliceBuffer(axis);
75162
+ if (buffer) {
75163
+ this.callbacks.renderSliceToCanvas(layer, axis, this.ctx.nrrd_states.view.currentSliceIndex, buffer, ctx, this.ctx.nrrd_states.view.changedWidth, this.ctx.nrrd_states.view.changedHeight);
75164
+ }
75165
+ this.callbacks.compositeAllLayers();
75166
+ this.ctx.protectedData.mainPreSlices.mesh.material.map.needsUpdate = true;
75167
+ }
74749
75168
  // ── Private helpers ────────────────────────────────────────
74750
75169
  /** Capture pre-draw slice snapshot for undo */
74751
75170
  capturePreDrawSnapshot() {
@@ -74782,27 +75201,18 @@ void main() {
74782
75201
  }
74783
75202
  /**
74784
75203
  * Redraws persisted layer data onto ctx before new pencil fill.
74785
- * Extracted from DrawToolCore.
75204
+ * Delegates to the host's vector renderSliceToCanvas for consistency.
74786
75205
  */
74787
75206
  redrawPreviousImageToLayerCtx(ctx) {
74788
- var _a;
74789
- const tempPreImg = (_a = this.callbacks.filterDrawedImage(this.ctx.protectedData.axis, this.ctx.nrrd_states.view.currentSliceIndex)) === null || _a === void 0 ? void 0 : _a.image;
74790
- this.ctx.protectedData.canvases.emptyCanvas.width =
74791
- this.ctx.protectedData.canvases.emptyCanvas.width;
74792
- this.ctx.protectedData.ctxes.emptyCtx.putImageData(tempPreImg, 0, 0);
74793
- ctx.imageSmoothingEnabled = false;
74794
- // Coronal (axis='y') Z-flip: same as renderSliceToCanvas.
74795
- if (this.ctx.protectedData.axis === 'y') {
74796
- ctx.save();
74797
- ctx.scale(1, -1);
74798
- ctx.translate(0, -this.ctx.nrrd_states.view.changedHeight);
74799
- }
74800
- ctx.drawImage(this.ctx.protectedData.canvases.emptyCanvas, 0, 0, this.ctx.nrrd_states.view.changedWidth, this.ctx.nrrd_states.view.changedHeight);
74801
- if (this.ctx.protectedData.axis === 'y') {
74802
- ctx.restore();
74803
- }
75207
+ const axis = this.ctx.protectedData.axis;
75208
+ const W = this.ctx.nrrd_states.view.changedWidth;
75209
+ const H = this.ctx.nrrd_states.view.changedHeight;
75210
+ const buffer = this.callbacks.getOrCreateSliceBuffer(axis);
75211
+ if (!buffer)
75212
+ return;
75213
+ this.callbacks.renderSliceToCanvas(this.ctx.gui_states.layerChannel.layer, axis, this.ctx.nrrd_states.view.currentSliceIndex, buffer, ctx, W, H);
74804
75214
  }
74805
- /** Draw a line segment on a layer canvas */
75215
+ /** Draw a line segment on a layer canvas (pencil mode only) */
74806
75216
  drawLinesOnLayer(ctx, x, y) {
74807
75217
  ctx.beginPath();
74808
75218
  ctx.moveTo(this.ctx.nrrd_states.interaction.drawStartPos.x, this.ctx.nrrd_states.interaction.drawStartPos.y);
@@ -74818,17 +75228,13 @@ void main() {
74818
75228
  ctx.stroke();
74819
75229
  ctx.closePath();
74820
75230
  }
74821
- /** Paint a segment on the current layer canvas and composite to master */
75231
+ /** Paint a segment on the current layer canvas and composite to master (pencil mode) */
74822
75232
  paintOnCanvasLayer(x, y) {
74823
75233
  const { ctx } = this.callbacks.setCurrentLayer();
74824
- // Draw only on the current layer canvas (not master directly)
74825
75234
  this.drawLinesOnLayer(ctx, x, y);
74826
- // Composite all layers to master to preserve other layers' data
74827
75235
  this.callbacks.compositeAllLayers();
74828
- // Reset drawing start position to current position
74829
75236
  this.ctx.nrrd_states.interaction.drawStartPos.x = x;
74830
75237
  this.ctx.nrrd_states.interaction.drawStartPos.y = y;
74831
- // Flag the map as needing updating
74832
75238
  this.ctx.protectedData.mainPreSlices.mesh.material.map.needsUpdate = true;
74833
75239
  }
74834
75240
  }
@@ -74968,7 +75374,11 @@ void main() {
74968
75374
  class SphereBrushTool extends BaseTool {
74969
75375
  constructor(ctx, callbacks) {
74970
75376
  super(ctx);
74971
- /** Recorded sphere center in canvas mm-space */
75377
+ /**
75378
+ * Recorded sphere center in display (zoomed) coordinates.
75379
+ * Used for both preview redraw and voxel calculation — the voxel conversion
75380
+ * uses `changedWidth/Height` as the display scale, so no mm detour is needed.
75381
+ */
74972
75382
  this.centerX = 0;
74973
75383
  this.centerY = 0;
74974
75384
  this.centerSlice = 0;
@@ -74976,10 +75386,24 @@ void main() {
74976
75386
  this.active = false;
74977
75387
  /** Current operation mode for the active placement */
74978
75388
  this.mode = "brush";
74979
- /** Cumulative "before" snapshots for drag-erase undo (z-index → slice data) */
75389
+ /** Cumulative "before" snapshots for drag undo (z-index → slice data) — used by both brush and eraser drag */
74980
75390
  this.dragBeforeSnapshots = new Map();
74981
- /** Whether a drag-erase has actually moved (vs. simple click-release) */
75391
+ /** Whether a drag has actually moved (vs. simple click-release) */
74982
75392
  this.dragMoved = false;
75393
+ /**
75394
+ * Latest cursor position in display (zoomed) coords. Used by the wheel
75395
+ * handler to redraw the preview at the cursor, not the original mousedown
75396
+ * point, during mid-drag radius changes.
75397
+ */
75398
+ this.lastDisplayX = 0;
75399
+ this.lastDisplayY = 0;
75400
+ this.lastSliceIndex = 0;
75401
+ /**
75402
+ * All sphere centers written during the current stroke. The wheel handler
75403
+ * retroactively re-renders the entire stroke at the new radius by restoring
75404
+ * `dragBeforeSnapshots` and replaying every entry here.
75405
+ */
75406
+ this.strokeCenters = [];
74983
75407
  this.callbacks = callbacks;
74984
75408
  }
74985
75409
  setCallbacks(callbacks) {
@@ -74987,41 +75411,66 @@ void main() {
74987
75411
  }
74988
75412
  // ── Geometry (ported from SphereTool) ──────────────────────────
74989
75413
  /**
74990
- * Convert canvas mm-space coordinates to 3D voxel coordinates.
75414
+ * Convert display (zoomed) coordinates to 3D voxel coordinates.
75415
+ *
75416
+ * Uses `changedWidth/Height` as the display scale directly (instead of
75417
+ * going through mm via `/sizeFactor`). This keeps the write pixel-exact
75418
+ * with the preview circle, which is drawn on a `changedWidth x changedHeight`
75419
+ * sized sphere canvas — avoiding floating-point drift at high zoom levels.
74991
75420
  */
74992
- canvasToVoxelCenter(canvasX, canvasY, sliceIndex, axis) {
75421
+ canvasToVoxelCenter(displayX, displayY, sliceIndex, axis) {
74993
75422
  const nrrd = this.ctx.nrrd_states;
75423
+ const cw = nrrd.view.changedWidth;
75424
+ const ch = nrrd.view.changedHeight;
74994
75425
  switch (axis) {
74995
75426
  case 'z':
74996
75427
  return {
74997
- x: canvasX * nrrd.image.nrrd_x_pixel / nrrd.image.nrrd_x_mm,
74998
- y: canvasY * nrrd.image.nrrd_y_pixel / nrrd.image.nrrd_y_mm,
75428
+ x: displayX * nrrd.image.nrrd_x_pixel / cw,
75429
+ y: displayY * nrrd.image.nrrd_y_pixel / ch,
74999
75430
  z: sliceIndex,
75000
75431
  };
75001
75432
  case 'y':
75002
75433
  return {
75003
- x: canvasX * nrrd.image.nrrd_x_pixel / nrrd.image.nrrd_x_mm,
75434
+ x: displayX * nrrd.image.nrrd_x_pixel / cw,
75004
75435
  y: sliceIndex,
75005
- z: (nrrd.image.nrrd_z_mm - canvasY) * nrrd.image.nrrd_z_pixel / nrrd.image.nrrd_z_mm,
75436
+ // Coronal vertical flip: display Y=0 is top, voxel Z increases downward in mm-space
75437
+ z: (ch - displayY) * nrrd.image.nrrd_z_pixel / ch,
75006
75438
  };
75007
75439
  case 'x':
75008
75440
  return {
75009
75441
  x: sliceIndex,
75010
- y: canvasY * nrrd.image.nrrd_y_pixel / nrrd.image.nrrd_y_mm,
75011
- z: canvasX * nrrd.image.nrrd_z_pixel / nrrd.image.nrrd_z_mm,
75442
+ y: displayY * nrrd.image.nrrd_y_pixel / ch,
75443
+ z: displayX * nrrd.image.nrrd_z_pixel / cw,
75012
75444
  };
75013
75445
  }
75014
75446
  }
75015
75447
  /**
75016
75448
  * Convert mm radius to per-axis voxel radii.
75449
+ *
75450
+ * For the two axes visible in the current view, scale via
75451
+ * `changedWidth/Height` so the rendered ellipsoid cross-section matches
75452
+ * the preview pixel radius exactly. The third (slice-direction) axis is
75453
+ * not visible, so it uses the plain mm-based ratio.
75017
75454
  */
75018
- getVoxelRadii(radius) {
75455
+ getVoxelRadii(radius, axis) {
75019
75456
  const nrrd = this.ctx.nrrd_states;
75020
- return {
75021
- rx: radius * nrrd.image.nrrd_x_pixel / nrrd.image.nrrd_x_mm,
75022
- ry: radius * nrrd.image.nrrd_y_pixel / nrrd.image.nrrd_y_mm,
75023
- rz: radius * nrrd.image.nrrd_z_pixel / nrrd.image.nrrd_z_mm,
75024
- };
75457
+ const sf = nrrd.view.sizeFactor;
75458
+ const cw = nrrd.view.changedWidth;
75459
+ const ch = nrrd.view.changedHeight;
75460
+ // pixel-exact voxel radius for the visible horizontal/vertical dims
75461
+ const rxView = radius * sf * nrrd.image.nrrd_x_pixel / cw;
75462
+ const ryView = radius * sf * nrrd.image.nrrd_y_pixel / ch;
75463
+ const rzHoriz = radius * sf * nrrd.image.nrrd_z_pixel / cw;
75464
+ const rzVert = radius * sf * nrrd.image.nrrd_z_pixel / ch;
75465
+ // mm-based fallback for the slice-direction (perpendicular) dim
75466
+ const rxMm = radius * nrrd.image.nrrd_x_pixel / nrrd.image.nrrd_x_mm;
75467
+ const ryMm = radius * nrrd.image.nrrd_y_pixel / nrrd.image.nrrd_y_mm;
75468
+ const rzMm = radius * nrrd.image.nrrd_z_pixel / nrrd.image.nrrd_z_mm;
75469
+ switch (axis) {
75470
+ case 'z': return { rx: rxView, ry: ryView, rz: rzMm };
75471
+ case 'y': return { rx: rxView, ry: ryMm, rz: rzVert };
75472
+ case 'x': return { rx: rxMm, ry: ryView, rz: rzHoriz };
75473
+ }
75025
75474
  }
75026
75475
  /**
75027
75476
  * Compute clamped bounding box for an ellipsoid in voxel space.
@@ -75126,11 +75575,15 @@ void main() {
75126
75575
  drawPreview(mouseX, mouseY, radius, isEraser) {
75127
75576
  const sphereCanvas = this.ctx.protectedData.canvases.drawingSphereCanvas;
75128
75577
  const sphereCtx = this.ctx.protectedData.ctxes.drawingSphereCtx;
75129
- // Clear and resize sphere canvas
75130
- sphereCanvas.width = this.ctx.protectedData.canvases.originCanvas.width;
75131
- sphereCanvas.height = this.ctx.protectedData.canvases.originCanvas.height;
75578
+ // Size sphere canvas to the zoomed display size so start() draws it 1:1 —
75579
+ // no scaling step, eliminating any zoom-dependent positioning error.
75580
+ const sf = this.ctx.nrrd_states.view.sizeFactor;
75581
+ sphereCanvas.width = this.ctx.nrrd_states.view.changedWidth;
75582
+ sphereCanvas.height = this.ctx.nrrd_states.view.changedHeight;
75583
+ // mouseX/mouseY are already in zoomed display coords; scale radius to match.
75584
+ const scaledRadius = radius * sf;
75132
75585
  sphereCtx.beginPath();
75133
- sphereCtx.arc(mouseX, mouseY, radius, 0, 2 * Math.PI);
75586
+ sphereCtx.arc(mouseX, mouseY, scaledRadius, 0, 2 * Math.PI);
75134
75587
  if (isEraser) {
75135
75588
  // Eraser preview: dashed outline
75136
75589
  sphereCtx.strokeStyle = "#ff4444";
@@ -75183,38 +75636,135 @@ void main() {
75183
75636
  return (e) => {
75184
75637
  e.preventDefault();
75185
75638
  const sphere = this.ctx.nrrd_states.sphere;
75186
- if (e.deltaY < 0) {
75187
- sphere.sphereBrushRadius += 1;
75188
- }
75189
- else {
75190
- sphere.sphereBrushRadius -= 1;
75639
+ sphere.sphereBrushRadius = Math.max(1, Math.min(sphere.sphereBrushRadius + (e.deltaY < 0 ? 1 : -1), 50));
75640
+ // 3D Slicer-style retroactive resize: if the user has already started
75641
+ // painting/erasing a stroke, re-render the entire stroke at the new
75642
+ // radius so the whole pipe/erase-path resizes together.
75643
+ if (this.active && this.dragMoved && this.strokeCenters.length > 0) {
75644
+ this.replayStrokeWithCurrentRadius();
75191
75645
  }
75192
- sphere.sphereBrushRadius = Math.max(1, Math.min(sphere.sphereBrushRadius, 50));
75193
- // Redraw preview
75646
+ // Preview always follows the latest cursor position, not the mousedown
75647
+ // point — otherwise the preview circle appears stuck on click-down when
75648
+ // the user scrolls after dragging elsewhere.
75194
75649
  if (this.active) {
75195
- this.drawPreview(this.centerX, this.centerY, sphere.sphereBrushRadius, this.mode === "eraser");
75650
+ this.drawPreview(this.lastDisplayX, this.lastDisplayY, sphere.sphereBrushRadius, this.mode === "eraser");
75196
75651
  }
75197
75652
  };
75198
75653
  }
75654
+ /**
75655
+ * Retroactively re-render the current stroke at the current radius.
75656
+ *
75657
+ * Step 1: walk every recorded stroke center and expand
75658
+ * `dragBeforeSnapshots` to cover the new (larger) bounding box. Slices
75659
+ * newly included here are still pristine because the original stroke
75660
+ * never touched them.
75661
+ * Step 2: restore every snapshotted slice from its pristine backup.
75662
+ * Step 3: replay every stroke center at the new radius.
75663
+ *
75664
+ * Step 3 produces the same final state as if the user had used the new
75665
+ * radius from the start. Undo still captures the full stroke as one group
75666
+ * at pointerup.
75667
+ */
75668
+ replayStrokeWithCurrentRadius() {
75669
+ if (this.strokeCenters.length === 0)
75670
+ return;
75671
+ const layer = this.ctx.gui_states.layerChannel.layer;
75672
+ const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
75673
+ const axis = this.ctx.protectedData.axis;
75674
+ const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
75675
+ let vol;
75676
+ try {
75677
+ vol = this.callbacks.getVolumeForLayer(layer);
75678
+ }
75679
+ catch (_a) {
75680
+ return;
75681
+ }
75682
+ const dims = vol.getDimensions();
75683
+ // 1. Expand snapshot coverage using the new radius so any newly-exposed
75684
+ // Z slices are captured while still pristine.
75685
+ for (const sc of this.strokeCenters) {
75686
+ const center = this.canvasToVoxelCenter(sc.x, sc.y, sc.sliceIndex, axis);
75687
+ const radii = this.getVoxelRadii(radius, axis);
75688
+ const bb = this.getBoundingBox(center, radii, dims);
75689
+ this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
75690
+ }
75691
+ // 2. Restore every snapshotted slice from pristine backup.
75692
+ for (const [z, pristine] of this.dragBeforeSnapshots) {
75693
+ try {
75694
+ vol.setSliceUint8(z, pristine, 'z');
75695
+ }
75696
+ catch (_b) {
75697
+ // Slice write failed — skip silently so a single bad slice doesn't break the replay.
75698
+ }
75699
+ }
75700
+ // 3. Replay every stroke center with the new radius.
75701
+ for (const sc of this.strokeCenters) {
75702
+ const center = this.canvasToVoxelCenter(sc.x, sc.y, sc.sliceIndex, axis);
75703
+ const radii = this.getVoxelRadii(radius, axis);
75704
+ const bb = this.getBoundingBox(center, radii, dims);
75705
+ if (this.mode === "brush") {
75706
+ this.write3DSphereToBrush(vol, center, radii, bb, channel);
75707
+ }
75708
+ else {
75709
+ this.erase3DSphereFromVolume(vol, center, radii, bb, channel);
75710
+ }
75711
+ }
75712
+ this.refreshDisplay(layer, undefined, false);
75713
+ }
75199
75714
  // ── Sphere Brush Click/PointerUp ───────────────────────────────
75200
75715
  /**
75201
- * Handle pointer-down in sphereBrush mode (direct click, no Shift needed).
75716
+ * Handle pointer-down in sphereBrush mode (3D Slicer-style).
75717
+ *
75718
+ * Captures pristine snapshots at the mousedown location but does NOT
75719
+ * write any voxels yet — the user may wheel-adjust the radius before
75720
+ * dragging. The first sphere is written either by the first pointermove
75721
+ * or by pointerup (single-click fallback).
75202
75722
  */
75203
75723
  onSphereBrushClick(e) {
75204
- this.centerX = e.offsetX / this.ctx.nrrd_states.view.sizeFactor;
75205
- this.centerY = e.offsetY / this.ctx.nrrd_states.view.sizeFactor;
75724
+ this.centerX = e.offsetX;
75725
+ this.centerY = e.offsetY;
75206
75726
  this.centerSlice = this.ctx.nrrd_states.view.currentSliceIndex;
75727
+ this.lastDisplayX = this.centerX;
75728
+ this.lastDisplayY = this.centerY;
75729
+ this.lastSliceIndex = this.centerSlice;
75207
75730
  this.active = true;
75208
75731
  this.mode = "brush";
75209
- this.drawPreview(this.centerX, this.centerY, this.ctx.nrrd_states.sphere.sphereBrushRadius, false);
75732
+ this.dragMoved = false;
75733
+ this.dragBeforeSnapshots.clear();
75734
+ this.strokeCenters = [{ x: this.centerX, y: this.centerY, sliceIndex: this.centerSlice }];
75735
+ const layer = this.ctx.gui_states.layerChannel.layer;
75736
+ const axis = this.ctx.protectedData.axis;
75737
+ const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
75738
+ try {
75739
+ const vol = this.callbacks.getVolumeForLayer(layer);
75740
+ const dims = vol.getDimensions();
75741
+ const center = this.canvasToVoxelCenter(this.centerX, this.centerY, this.centerSlice, axis);
75742
+ const radii = this.getVoxelRadii(radius, axis);
75743
+ const bb = this.getBoundingBox(center, radii, dims);
75744
+ this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
75745
+ }
75746
+ catch (_a) {
75747
+ // Volume not ready — preview still shown so user gets visual feedback
75748
+ }
75749
+ this.drawPreview(this.centerX, this.centerY, radius, false);
75210
75750
  }
75211
75751
  /**
75212
- * Handle pointer-up in sphereBrush mode — write sphere to volume.
75752
+ * Handle pointer-move in sphereBrush mode — drag to continuously paint.
75753
+ *
75754
+ * Mirrors sphereEraser drag: writes a new sphere at each move position,
75755
+ * extends the cumulative before-snapshot to cover any newly-touched Z
75756
+ * slices, and repaints the preview. Never notifies the backend; pointerup
75757
+ * does that once for the whole stroke.
75213
75758
  */
75214
- onSphereBrushPointerUp() {
75759
+ onSphereBrushMove(e) {
75215
75760
  if (!this.active || this.mode !== "brush")
75216
75761
  return;
75217
- this.active = false;
75762
+ this.dragMoved = true;
75763
+ const sliceIndex = this.ctx.nrrd_states.view.currentSliceIndex;
75764
+ this.lastDisplayX = e.offsetX;
75765
+ this.lastDisplayY = e.offsetY;
75766
+ this.lastSliceIndex = sliceIndex;
75767
+ this.strokeCenters.push({ x: e.offsetX, y: e.offsetY, sliceIndex });
75218
75768
  const layer = this.ctx.gui_states.layerChannel.layer;
75219
75769
  const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
75220
75770
  const axis = this.ctx.protectedData.axis;
@@ -75224,26 +75774,56 @@ void main() {
75224
75774
  vol = this.callbacks.getVolumeForLayer(layer);
75225
75775
  }
75226
75776
  catch (_a) {
75227
- this.clearPreview();
75228
75777
  return;
75229
75778
  }
75230
75779
  const dims = vol.getDimensions();
75231
- const center = this.canvasToVoxelCenter(this.centerX, this.centerY, this.centerSlice, axis);
75232
- const radii = this.getVoxelRadii(radius);
75780
+ const center = this.canvasToVoxelCenter(e.offsetX, e.offsetY, sliceIndex, axis);
75781
+ const radii = this.getVoxelRadii(radius, axis);
75233
75782
  const bb = this.getBoundingBox(center, radii, dims);
75234
- // Capture pre-write snapshots
75235
- const before = this.captureSliceSnapshots(vol, bb.minZ, bb.maxZ);
75236
- // Write sphere
75783
+ this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
75237
75784
  this.write3DSphereToBrush(vol, center, radii, bb, channel);
75238
- // Capture post-write snapshots and push undo group
75239
- const after = this.captureSliceSnapshots(vol, bb.minZ, bb.maxZ);
75240
- const deltas = this.buildUndoGroup(layer, before, after);
75785
+ this.drawPreview(e.offsetX, e.offsetY, radius, false);
75786
+ this.refreshDisplay(layer, undefined, false);
75787
+ }
75788
+ /**
75789
+ * Handle pointer-up in sphereBrush mode — finalize stroke + push undo group
75790
+ * + single batched backend notify for all Z slices touched during the drag.
75791
+ */
75792
+ onSphereBrushPointerUp() {
75793
+ if (!this.active || this.mode !== "brush")
75794
+ return;
75795
+ this.active = false;
75796
+ const layer = this.ctx.gui_states.layerChannel.layer;
75797
+ const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
75798
+ const axis = this.ctx.protectedData.axis;
75799
+ const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
75800
+ let vol;
75801
+ try {
75802
+ vol = this.callbacks.getVolumeForLayer(layer);
75803
+ }
75804
+ catch (_a) {
75805
+ this.finalizeStrokeState();
75806
+ return;
75807
+ }
75808
+ // Click-without-drag: write the single sphere now so a plain click still
75809
+ // paints something. onSphereBrushClick intentionally skipped this so the
75810
+ // user could wheel-adjust radius first.
75811
+ if (!this.dragMoved && this.strokeCenters.length > 0) {
75812
+ const sc = this.strokeCenters[0];
75813
+ const dims = vol.getDimensions();
75814
+ const center = this.canvasToVoxelCenter(sc.x, sc.y, sc.sliceIndex, axis);
75815
+ const radii = this.getVoxelRadii(radius, axis);
75816
+ const bb = this.getBoundingBox(center, radii, dims);
75817
+ this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
75818
+ this.write3DSphereToBrush(vol, center, radii, bb, channel);
75819
+ }
75820
+ const after = this.captureSliceSnapshots(vol, this.dragMinZ(), this.dragMaxZ());
75821
+ const deltas = this.buildUndoGroup(layer, this.dragBeforeSnapshots, after);
75241
75822
  if (deltas.length > 0) {
75242
75823
  this.callbacks.pushUndoGroup(deltas);
75243
75824
  }
75244
- // Refresh display and notify backend of ALL changed slices
75245
- this.refreshDisplay(layer, deltas);
75246
- this.clearPreview();
75825
+ this.refreshDisplay(layer, deltas, true);
75826
+ this.finalizeStrokeState();
75247
75827
  }
75248
75828
  // ── Sphere Eraser Click/PointerUp ──────────────────────────────
75249
75829
  /**
@@ -75251,13 +75831,17 @@ void main() {
75251
75831
  * Initializes drag tracking for cumulative undo.
75252
75832
  */
75253
75833
  onSphereEraserClick(e) {
75254
- this.centerX = e.offsetX / this.ctx.nrrd_states.view.sizeFactor;
75255
- this.centerY = e.offsetY / this.ctx.nrrd_states.view.sizeFactor;
75834
+ this.centerX = e.offsetX;
75835
+ this.centerY = e.offsetY;
75256
75836
  this.centerSlice = this.ctx.nrrd_states.view.currentSliceIndex;
75837
+ this.lastDisplayX = this.centerX;
75838
+ this.lastDisplayY = this.centerY;
75839
+ this.lastSliceIndex = this.centerSlice;
75257
75840
  this.active = true;
75258
75841
  this.mode = "eraser";
75259
75842
  this.dragMoved = false;
75260
75843
  this.dragBeforeSnapshots.clear();
75844
+ this.strokeCenters = [{ x: this.centerX, y: this.centerY, sliceIndex: this.centerSlice }];
75261
75845
  // Capture initial "before" snapshots for the first sphere position
75262
75846
  const layer = this.ctx.gui_states.layerChannel.layer;
75263
75847
  const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
@@ -75266,7 +75850,7 @@ void main() {
75266
75850
  const dims = vol.getDimensions();
75267
75851
  const axis = this.ctx.protectedData.axis;
75268
75852
  const center = this.canvasToVoxelCenter(this.centerX, this.centerY, this.centerSlice, axis);
75269
- const radii = this.getVoxelRadii(radius);
75853
+ const radii = this.getVoxelRadii(radius, axis);
75270
75854
  const bb = this.getBoundingBox(center, radii, dims);
75271
75855
  this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
75272
75856
  }
@@ -75282,9 +75866,11 @@ void main() {
75282
75866
  if (!this.active || this.mode !== "eraser")
75283
75867
  return;
75284
75868
  this.dragMoved = true;
75285
- const mouseX = e.offsetX / this.ctx.nrrd_states.view.sizeFactor;
75286
- const mouseY = e.offsetY / this.ctx.nrrd_states.view.sizeFactor;
75287
75869
  const sliceIndex = this.ctx.nrrd_states.view.currentSliceIndex;
75870
+ this.lastDisplayX = e.offsetX;
75871
+ this.lastDisplayY = e.offsetY;
75872
+ this.lastSliceIndex = sliceIndex;
75873
+ this.strokeCenters.push({ x: e.offsetX, y: e.offsetY, sliceIndex });
75288
75874
  const layer = this.ctx.gui_states.layerChannel.layer;
75289
75875
  const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
75290
75876
  const axis = this.ctx.protectedData.axis;
@@ -75297,17 +75883,18 @@ void main() {
75297
75883
  return;
75298
75884
  }
75299
75885
  const dims = vol.getDimensions();
75300
- const center = this.canvasToVoxelCenter(mouseX, mouseY, sliceIndex, axis);
75301
- const radii = this.getVoxelRadii(radius);
75886
+ const center = this.canvasToVoxelCenter(e.offsetX, e.offsetY, sliceIndex, axis);
75887
+ const radii = this.getVoxelRadii(radius, axis);
75302
75888
  const bb = this.getBoundingBox(center, radii, dims);
75303
75889
  // Expand "before" snapshots to cover any new Z slices
75304
75890
  this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
75305
75891
  // Erase sphere at current position
75306
75892
  this.erase3DSphereFromVolume(vol, center, radii, bb, channel);
75307
- // Update preview position
75308
- this.drawPreview(mouseX, mouseY, radius, true);
75309
- // Refresh display so user sees the erase in real-time
75310
- this.refreshDisplay(layer);
75893
+ // Update preview position (pass display/zoomed coords)
75894
+ this.drawPreview(e.offsetX, e.offsetY, radius, true);
75895
+ // Refresh display locally skip backend notify to avoid per-frame HTTP.
75896
+ // onSphereEraserPointerUp batches all deltas into a single notify.
75897
+ this.refreshDisplay(layer, undefined, false);
75311
75898
  }
75312
75899
  /**
75313
75900
  * Handle pointer-up in sphereEraser mode — finalize erase + push undo group.
@@ -75326,16 +75913,17 @@ void main() {
75326
75913
  vol = this.callbacks.getVolumeForLayer(layer);
75327
75914
  }
75328
75915
  catch (_a) {
75329
- this.clearPreview();
75330
- this.dragBeforeSnapshots.clear();
75916
+ this.finalizeStrokeState();
75331
75917
  return;
75332
75918
  }
75333
- // If no drag occurred, erase at the click position (original click-release behavior)
75334
- if (!this.dragMoved) {
75919
+ // Click-without-drag: erase at the click position (click-release behavior).
75920
+ if (!this.dragMoved && this.strokeCenters.length > 0) {
75921
+ const sc = this.strokeCenters[0];
75335
75922
  const dims = vol.getDimensions();
75336
- const center = this.canvasToVoxelCenter(this.centerX, this.centerY, this.centerSlice, axis);
75337
- const radii = this.getVoxelRadii(radius);
75923
+ const center = this.canvasToVoxelCenter(sc.x, sc.y, sc.sliceIndex, axis);
75924
+ const radii = this.getVoxelRadii(radius, axis);
75338
75925
  const bb = this.getBoundingBox(center, radii, dims);
75926
+ this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
75339
75927
  this.erase3DSphereFromVolume(vol, center, radii, bb, channel);
75340
75928
  }
75341
75929
  // Build undo group from cumulative "before" snapshots vs current state
@@ -75344,10 +75932,20 @@ void main() {
75344
75932
  if (deltas.length > 0) {
75345
75933
  this.callbacks.pushUndoGroup(deltas);
75346
75934
  }
75347
- // Refresh display and notify backend of ALL changed slices
75348
- this.refreshDisplay(layer, deltas);
75935
+ // Refresh display and notify backend of ALL changed slices — single HTTP batch
75936
+ this.refreshDisplay(layer, deltas, true);
75937
+ this.finalizeStrokeState();
75938
+ }
75939
+ /**
75940
+ * Clear per-stroke state after pointerup or cancel. Kept as a helper so
75941
+ * brush/eraser pointerup and cancelActivePlacement all reset the same set
75942
+ * of fields — if a new drag-state field is added, only one place to update.
75943
+ */
75944
+ finalizeStrokeState() {
75349
75945
  this.clearPreview();
75350
75946
  this.dragBeforeSnapshots.clear();
75947
+ this.strokeCenters = [];
75948
+ this.dragMoved = false;
75351
75949
  }
75352
75950
  /**
75353
75951
  * Expand cumulative "before" snapshots to cover z range [minZ, maxZ].
@@ -75392,8 +75990,11 @@ void main() {
75392
75990
  * @param changedDeltas - If provided, fire onMaskChanged for ALL changed
75393
75991
  * slices (not just the current view slice) so the backend receives the
75394
75992
  * full 3D sphere data for NII/GLTF export.
75993
+ * @param notifyBackend - When false, only refresh the local canvas and skip
75994
+ * the onMaskChanged callback. Used during drag to avoid per-frame HTTP;
75995
+ * pointerup then calls with `true` to send a single batched update.
75395
75996
  */
75396
- refreshDisplay(layerId, changedDeltas) {
75997
+ refreshDisplay(layerId, changedDeltas, notifyBackend = true) {
75397
75998
  // Re-render layer canvas from volume for the current slice
75398
75999
  const target = this.ctx.protectedData.layerTargets.get(layerId);
75399
76000
  if (target) {
@@ -75406,6 +76007,9 @@ void main() {
75406
76007
  }
75407
76008
  // Composite all layers to master canvas
75408
76009
  this.callbacks.compositeAllLayers();
76010
+ // Skip backend notify during drag — pointerup batches a single notify for the whole stroke.
76011
+ if (!notifyBackend)
76012
+ return;
75409
76013
  // Fire onMaskChanged for ALL changed slices (not just the current view slice)
75410
76014
  // This ensures the backend receives the full 3D sphere data for export.
75411
76015
  try {
@@ -75434,6 +76038,23 @@ void main() {
75434
76038
  get isActive() {
75435
76039
  return this.active;
75436
76040
  }
76041
+ /**
76042
+ * Defensively cancel any in-progress sphere placement and clear the preview.
76043
+ *
76044
+ * Called when crosshair mode is entered so any lingering sphere preview
76045
+ * (e.g. red dashed eraser outline) is wiped immediately. In normal flow
76046
+ * `leftButtonDown === true` blocks crosshair toggle, so this shouldn't
76047
+ * fire mid-stroke — but if it ever does, we drop the drag snapshot
76048
+ * rather than committing a partial undo entry.
76049
+ */
76050
+ cancelActivePlacement() {
76051
+ if (!this.active) {
76052
+ this.clearPreview();
76053
+ return;
76054
+ }
76055
+ this.active = false;
76056
+ this.finalizeStrokeState();
76057
+ }
75437
76058
  }
75438
76059
 
75439
76060
  /**
@@ -75538,6 +76159,8 @@ void main() {
75538
76159
  getVolumeForLayer: (layer) => this.renderer.getVolumeForLayer(layer),
75539
76160
  pushUndoDelta: (delta) => this.undoManager.push(delta),
75540
76161
  getEraserUrls: () => this.eraserUrls,
76162
+ renderSliceToCanvas: (layer, axis, sliceIndex, buffer, ctx, w, h) => this.renderer.renderSliceToCanvas(layer, axis, sliceIndex, buffer, ctx, w, h),
76163
+ getOrCreateSliceBuffer: (axis) => this.renderer.getOrCreateSliceBuffer(axis),
75541
76164
  });
75542
76165
  this.sphereBrushTool = new SphereBrushTool(toolCtx, {
75543
76166
  getVolumeForLayer: (layer) => this.renderer.getVolumeForLayer(layer),
@@ -75556,6 +76179,7 @@ void main() {
75556
76179
  container: this.container,
75557
76180
  canvas: this.state.protectedData.canvases.drawingCanvas,
75558
76181
  onModeChange: (prevMode, newMode) => {
76182
+ var _a;
75559
76183
  // Use string comparison to avoid TypeScript narrowing issues
75560
76184
  const prev = prevMode;
75561
76185
  const next = newMode;
@@ -75568,6 +76192,10 @@ void main() {
75568
76192
  }
75569
76193
  if (next === 'crosshair') {
75570
76194
  this.state.protectedData.isDrawing = false;
76195
+ // Wipe any lingering sphere preview (red dashed eraser outline /
76196
+ // filled brush preview) when the user toggles into crosshair from
76197
+ // sphereBrush/sphereEraser. Safe no-op when no placement is active.
76198
+ (_a = this.sphereBrushTool) === null || _a === void 0 ? void 0 : _a.cancelActivePlacement();
75571
76199
  }
75572
76200
  }
75573
76201
  });
@@ -75666,9 +76294,14 @@ void main() {
75666
76294
  if (this.drawingTool.isActive || this.panTool.isActive) {
75667
76295
  this.drawingPrameters.handleOnDrawingMouseMove(e);
75668
76296
  }
75669
- // Drag-erase: route move to sphereBrushTool when sphereEraser is active
75670
- if (this.sphereBrushTool.isActive && this.state.gui_states.mode.sphereEraser) {
75671
- this.sphereBrushTool.onSphereEraserMove(e);
76297
+ // Drag: route move to sphereBrushTool for both brush and eraser
76298
+ if (this.sphereBrushTool.isActive) {
76299
+ if (this.state.gui_states.mode.sphereEraser) {
76300
+ this.sphereBrushTool.onSphereEraserMove(e);
76301
+ }
76302
+ else if (this.state.gui_states.mode.sphereBrush) {
76303
+ this.sphereBrushTool.onSphereBrushMove(e);
76304
+ }
75672
76305
  }
75673
76306
  });
75674
76307
  this.eventRouter.setPointerUpHandler((e) => {
@@ -75967,6 +76600,11 @@ void main() {
75967
76600
  const ctx = this.state.protectedData.ctxes.emptyCtx;
75968
76601
  const w = this.state.protectedData.canvases.emptyCanvas.width;
75969
76602
  const h = this.state.protectedData.canvases.emptyCanvas.height;
76603
+ // Bake step: downscale layer canvas (display resolution) to emptyCanvas
76604
+ // (voxel resolution) before label extraction. Use nearest-neighbor
76605
+ // (smoothing=false) so voxel occupancy is determined directly by the
76606
+ // drawn pixels on the layer canvas, without blending that could shift
76607
+ // the alpha>=128 threshold decision in setSliceLabelsFromImageData.
75970
76608
  ctx.imageSmoothingEnabled = false;
75971
76609
  ctx.drawImage(canvas, 0, 0, w, h);
75972
76610
  }
@@ -77673,11 +78311,15 @@ void main() {
77673
78311
  this.state.gui_states.mode.sphereBrush = true;
77674
78312
  this.dragOperator.removeDragMode();
77675
78313
  (_a = this.drawCore.eventRouter) === null || _a === void 0 ? void 0 : _a.setGuiTool('sphereBrush');
78314
+ this.state.protectedData.canvases.drawingCanvas.style.cursor =
78315
+ this.state.gui_states.viewConfig.defaultPaintCursor;
77676
78316
  break;
77677
78317
  case "sphereEraser":
77678
78318
  this.state.gui_states.mode.sphereEraser = true;
77679
78319
  this.dragOperator.removeDragMode();
77680
78320
  (_b = this.drawCore.eventRouter) === null || _b === void 0 ? void 0 : _b.setGuiTool('sphereEraser');
78321
+ this.state.protectedData.canvases.drawingCanvas.style.cursor =
78322
+ this.state.gui_states.viewConfig.defaultPaintCursor;
77681
78323
  break;
77682
78324
  }
77683
78325
  // Restore drag mode when leaving sphereBrush/sphereEraser
@@ -78405,6 +79047,42 @@ void main() {
78405
79047
  drawCalculatorSphereOnEachViews(axis) {
78406
79048
  this.drawCore.drawCalculatorSphereOnEachViews(axis);
78407
79049
  }
79050
+ /**
79051
+ * Copy all voxel data from one layer's MaskVolume to another.
79052
+ *
79053
+ * After the copy the target layer's display is refreshed so the UI
79054
+ * shows the updated data immediately. The target layer's color map
79055
+ * is preserved — only the raw voxel buffer is overwritten.
79056
+ *
79057
+ * Use case: when editing layer2 cascades to layer3 on the backend,
79058
+ * call this to keep the frontend in sync without a network round-trip.
79059
+ *
79060
+ * @param sourceLayerId Layer to copy from (e.g. "layer2").
79061
+ * @param targetLayerId Layer to copy into (e.g. "layer3").
79062
+ *
79063
+ * @example
79064
+ * ```ts
79065
+ * nrrdTools.copyLayerData("layer2", "layer3");
79066
+ * ```
79067
+ */
79068
+ copyLayerData(sourceLayerId, targetLayerId) {
79069
+ const srcVol = this.drawCore.renderer.getVolumeForLayer(sourceLayerId);
79070
+ const dstVol = this.drawCore.renderer.getVolumeForLayer(targetLayerId);
79071
+ if (!srcVol || !dstVol) {
79072
+ console.warn(`copyLayerData: cannot resolve volumes for "${sourceLayerId}" → "${targetLayerId}"`);
79073
+ return;
79074
+ }
79075
+ const srcData = srcVol.getRawData();
79076
+ const dstData = dstVol.getRawData();
79077
+ if (srcData.length !== dstData.length) {
79078
+ console.warn(`copyLayerData: size mismatch (${srcData.length} vs ${dstData.length})`);
79079
+ return;
79080
+ }
79081
+ // Copy voxel data (preserves target color map)
79082
+ dstVol.setRawData(new Uint8Array(srcData));
79083
+ // Refresh the display so all three axes reflect the new data
79084
+ this.reloadMasksFromVolume();
79085
+ }
78408
79086
  }
78409
79087
 
78410
79088
  class Node {
@@ -78614,7 +79292,7 @@ void main() {
78614
79292
  }
78615
79293
 
78616
79294
  // import * as kiwrious from "copper3d_plugin_heart_k";
78617
- const REVISION = "v3.3.2-beta";
79295
+ const REVISION = "v3.4.0-beta";
78618
79296
  console.log(`%cCopper3D Visualisation %cBeta:${REVISION}`, "padding: 3px;color:white; background:#023047", "padding: 3px;color:white; background:#f50a25");
78619
79297
 
78620
79298
  exports.CHANNEL_COLORS = CHANNEL_COLORS;