copper3d 3.3.3 → 3.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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.js +4 -0
  4. package/dist/Utils/segmentation/NrrdTools.js.map +1 -1
  5. package/dist/Utils/segmentation/RenderingUtils.d.ts +9 -3
  6. package/dist/Utils/segmentation/RenderingUtils.js +35 -15
  7. package/dist/Utils/segmentation/RenderingUtils.js.map +1 -1
  8. package/dist/Utils/segmentation/core/MarchingSquares.d.ts +72 -0
  9. package/dist/Utils/segmentation/core/MarchingSquares.js +169 -0
  10. package/dist/Utils/segmentation/core/MarchingSquares.js.map +1 -0
  11. package/dist/Utils/segmentation/core/index.d.ts +2 -0
  12. package/dist/Utils/segmentation/core/index.js +1 -0
  13. package/dist/Utils/segmentation/core/index.js.map +1 -1
  14. package/dist/Utils/segmentation/eventRouter/EventRouter.d.ts +9 -0
  15. package/dist/Utils/segmentation/eventRouter/EventRouter.js +36 -8
  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 +794 -131
  25. package/dist/bundle.umd.js +794 -131
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.js +1 -1
  28. package/dist/types/Utils/segmentation/RenderingUtils.d.ts +9 -3
  29. package/dist/types/Utils/segmentation/core/MarchingSquares.d.ts +72 -0
  30. package/dist/types/Utils/segmentation/core/index.d.ts +2 -0
  31. package/dist/types/Utils/segmentation/eventRouter/EventRouter.d.ts +9 -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
@@ -72064,6 +72064,175 @@ class UndoManager {
72064
72064
  }
72065
72065
  }
72066
72066
 
72067
+ /**
72068
+ * MarchingSquares — Extract vector contours from a 2D voxel label grid.
72069
+ *
72070
+ * For each label, produces a `Path2D` whose subpaths are small per-cell
72071
+ * polygons. The polygons tile the region occupied by the target label; the
72072
+ * union (via `ctx.fill(path, 'nonzero')`) is the label's silhouette on the
72073
+ * voxel grid.
72074
+ *
72075
+ * Coordinate convention:
72076
+ * - Voxel (i, j) occupies the unit square [i, i+1] × [j, j+1] in render
72077
+ * space, with its center at (i+0.5, j+0.5). This matches ITK-SNAP /
72078
+ * standard imaging viewers and the existing `renderSliceToCanvas`
72079
+ * `scale(scaledW / W, scaledH / H)` mapping.
72080
+ * - Marching squares treats voxels as point samples at their centers:
72081
+ * cell (i, j) has corners at voxel centers (i+0.5, j+0.5),
72082
+ * (i+1.5, j+0.5), (i+1.5, j+1.5), (i+0.5, j+1.5).
72083
+ * - This produces 45°-cut contours (diamonds for isolated voxels, rounded
72084
+ * corners for connected regions) that stay within the voxel-square
72085
+ * bounds, so the rendered silhouette is a slightly-inset version of the
72086
+ * ITK-SNAP silhouette with smooth diagonals.
72087
+ *
72088
+ * Saddle cases (5 and 10) use a fixed convention: the two in-corners are
72089
+ * treated as disconnected within the cell (4-connectivity interpretation).
72090
+ */
72091
+ /**
72092
+ * Per-cell polygon lookup for the 16 marching-squares cases.
72093
+ *
72094
+ * Corner bit layout: TL=8, TR=4, BR=2, BL=1
72095
+ *
72096
+ * Coordinates are within the unit cell, 0 ≤ x ≤ 1, 0 ≤ y ≤ 1,
72097
+ * where (0, 0) is the TL corner of the cell. Add (i, j) to get absolute.
72098
+ *
72099
+ * Each case may emit 0, 1, or 2 polygons (saddles → 2).
72100
+ * Polygon vertices are listed in a CCW order in screen space (y-down),
72101
+ * which corresponds to CW in math coords. Path2D `fill(nonzero)` handles
72102
+ * this consistently.
72103
+ */
72104
+ const CELL_POLYGONS = [
72105
+ /* 0 (empty) */ [],
72106
+ /* 1 (BL) */ [[[0, 0.5], [0.5, 1], [0, 1]]],
72107
+ /* 2 (BR) */ [[[0.5, 1], [1, 0.5], [1, 1]]],
72108
+ /* 3 (BR+BL) */ [[[0, 0.5], [1, 0.5], [1, 1], [0, 1]]],
72109
+ /* 4 (TR) */ [[[0.5, 0], [1, 0], [1, 0.5]]],
72110
+ /* 5 (TR+BL saddle) */ [
72111
+ [[0.5, 0], [1, 0], [1, 0.5]],
72112
+ [[0, 0.5], [0.5, 1], [0, 1]],
72113
+ ],
72114
+ /* 6 (TR+BR) */ [[[0.5, 0], [1, 0], [1, 1], [0.5, 1]]],
72115
+ /* 7 (TR+BR+BL, !TL) */ [[[0.5, 0], [1, 0], [1, 1], [0, 1], [0, 0.5]]],
72116
+ /* 8 (TL) */ [[[0, 0], [0.5, 0], [0, 0.5]]],
72117
+ /* 9 (TL+BL) */ [[[0, 0], [0.5, 0], [0.5, 1], [0, 1]]],
72118
+ /* 10 (TL+BR saddle) */ [
72119
+ [[0, 0], [0.5, 0], [0, 0.5]],
72120
+ [[0.5, 1], [1, 0.5], [1, 1]],
72121
+ ],
72122
+ /* 11 (TL+BR+BL, !TR) */ [[[0, 0], [0.5, 0], [1, 0.5], [1, 1], [0, 1]]],
72123
+ /* 12 (TL+TR) */ [[[0, 0], [1, 0], [1, 0.5], [0, 0.5]]],
72124
+ /* 13 (TL+TR+BL, !BR) */ [[[0, 0], [1, 0], [1, 0.5], [0.5, 1], [0, 1]]],
72125
+ /* 14 (TL+TR+BR, !BL) */ [[[0, 0], [1, 0], [1, 1], [0.5, 1], [0, 0.5]]],
72126
+ /* 15 (full) */ [[[0, 0], [1, 0], [1, 1], [0, 1]]],
72127
+ ];
72128
+ /**
72129
+ * Emit all per-cell polygons covering the voxels where `labels === targetLabel`.
72130
+ * Pure geometry function — no Canvas dependency. Consumers can build a Path2D
72131
+ * (see {@link extractLabelContours}) or walk the vertex arrays directly
72132
+ * (tests, export, stroke rendering).
72133
+ */
72134
+ function extractLabelPolygons(labels, width, height, targetLabel, stride = 1, channelOffset = 0, bbox) {
72135
+ var _a, _b, _c, _d;
72136
+ const out = [];
72137
+ const cx0 = ((_a = bbox === null || bbox === void 0 ? void 0 : bbox.x0) !== null && _a !== void 0 ? _a : 0) - 1;
72138
+ const cy0 = ((_b = bbox === null || bbox === void 0 ? void 0 : bbox.y0) !== null && _b !== void 0 ? _b : 0) - 1;
72139
+ const cx1 = (_c = bbox === null || bbox === void 0 ? void 0 : bbox.x1) !== null && _c !== void 0 ? _c : width;
72140
+ const cy1 = (_d = bbox === null || bbox === void 0 ? void 0 : bbox.y1) !== null && _d !== void 0 ? _d : height;
72141
+ const i0 = Math.max(-1, cx0);
72142
+ const j0 = Math.max(-1, cy0);
72143
+ const i1 = Math.min(width, cx1);
72144
+ const j1 = Math.min(height, cy1);
72145
+ const sample = (x, y) => {
72146
+ if (x < 0 || x >= width || y < 0 || y >= height)
72147
+ return false;
72148
+ return labels[(y * width + x) * stride + channelOffset] === targetLabel;
72149
+ };
72150
+ // Shift output by +0.5 so that marching-squares samples (voxel positions)
72151
+ // map to voxel-square CENTERS in render space. Voxel (i, j) occupies
72152
+ // render square [i, i+1] × [j, j+1]; its center is (i+0.5, j+0.5).
72153
+ const SHIFT = 0.5;
72154
+ for (let j = j0; j < j1; j++) {
72155
+ for (let i = i0; i < i1; i++) {
72156
+ const tl = sample(i, j);
72157
+ const tr = sample(i + 1, j);
72158
+ const br = sample(i + 1, j + 1);
72159
+ const bl = sample(i, j + 1);
72160
+ const code = (tl ? 8 : 0) |
72161
+ (tr ? 4 : 0) |
72162
+ (br ? 2 : 0) |
72163
+ (bl ? 1 : 0);
72164
+ if (code === 0)
72165
+ continue;
72166
+ const polygons = CELL_POLYGONS[code];
72167
+ for (let p = 0; p < polygons.length; p++) {
72168
+ const poly = polygons[p];
72169
+ const abs = new Array(poly.length);
72170
+ for (let k = 0; k < poly.length; k++) {
72171
+ abs[k] = [i + poly[k][0] + SHIFT, j + poly[k][1] + SHIFT];
72172
+ }
72173
+ out.push(abs);
72174
+ }
72175
+ }
72176
+ }
72177
+ return out;
72178
+ }
72179
+ /**
72180
+ * Extract a `Path2D` covering all voxels equal to `targetLabel`.
72181
+ *
72182
+ * @param labels Flat label array. Addressed as
72183
+ * `labels[(y * width + x) * stride + channelOffset]`.
72184
+ * @param width Grid width in voxels.
72185
+ * @param height Grid height in voxels.
72186
+ * @param targetLabel Label value to extract (1..255; 0 is background).
72187
+ * @param stride Bytes per voxel (default 1). Use MaskVolume.numChannels
72188
+ * when reading interleaved slices.
72189
+ * @param channelOffset Offset within each voxel's channels to sample.
72190
+ * Default 0 (first channel = label id).
72191
+ * @param bbox Optional voxel-space bbox to limit extraction.
72192
+ * Cells in a 1-voxel halo around the bbox are still
72193
+ * visited so the contour closes correctly at the bbox edges.
72194
+ */
72195
+ function extractLabelContours(labels, width, height, targetLabel, stride = 1, channelOffset = 0, bbox) {
72196
+ const polygons = extractLabelPolygons(labels, width, height, targetLabel, stride, channelOffset, bbox);
72197
+ const path = new Path2D();
72198
+ for (let p = 0; p < polygons.length; p++) {
72199
+ const poly = polygons[p];
72200
+ path.moveTo(poly[0][0], poly[0][1]);
72201
+ for (let k = 1; k < poly.length; k++) {
72202
+ path.lineTo(poly[k][0], poly[k][1]);
72203
+ }
72204
+ path.closePath();
72205
+ }
72206
+ return path;
72207
+ }
72208
+ /**
72209
+ * Detect the distinct non-zero labels present in a slice region.
72210
+ *
72211
+ * @param labels Flat label array (see {@link extractLabelContours}).
72212
+ * @param width Grid width.
72213
+ * @param height Grid height.
72214
+ * @param stride Bytes per voxel.
72215
+ * @param channelOffset Offset within each voxel.
72216
+ * @param bbox Optional region to scan (defaults to full grid).
72217
+ * @returns Sorted array of distinct label values (0 omitted).
72218
+ */
72219
+ function findLabelsInSlice(labels, width, height, stride = 1, channelOffset = 0, bbox) {
72220
+ var _a, _b, _c, _d;
72221
+ const x0 = (_a = bbox === null || bbox === void 0 ? void 0 : bbox.x0) !== null && _a !== void 0 ? _a : 0;
72222
+ const y0 = (_b = bbox === null || bbox === void 0 ? void 0 : bbox.y0) !== null && _b !== void 0 ? _b : 0;
72223
+ const x1 = (_c = bbox === null || bbox === void 0 ? void 0 : bbox.x1) !== null && _c !== void 0 ? _c : width;
72224
+ const y1 = (_d = bbox === null || bbox === void 0 ? void 0 : bbox.y1) !== null && _d !== void 0 ? _d : height;
72225
+ const seen = new Set();
72226
+ for (let y = y0; y < y1; y++) {
72227
+ for (let x = x0; x < x1; x++) {
72228
+ const v = labels[(y * width + x) * stride + channelOffset];
72229
+ if (v !== 0)
72230
+ seen.add(v);
72231
+ }
72232
+ }
72233
+ return Array.from(seen).sort((a, b) => a - b);
72234
+ }
72235
+
72067
72236
  /**
72068
72237
  * BaseTool - Abstract base for extracted DrawToolCore tools
72069
72238
  *
@@ -73551,35 +73720,54 @@ class RenderingUtils {
73551
73720
  }
73552
73721
  }
73553
73722
  /**
73554
- * Render a layer's slice into a reusable buffer and draw to the target canvas.
73723
+ * Render a layer's slice onto the target canvas as vector contours.
73724
+ *
73725
+ * Uses marching-squares to extract voxel-truthful Path2D contours per
73726
+ * label, then `ctx.fill()` them at the display canvas resolution. This
73727
+ * eliminates the bilinear-upscale blur and zoom-dependent shape drift
73728
+ * that plagued the old putImageData → drawImage pipeline.
73555
73729
  *
73556
- * Uses MaskVolume.renderLabelSliceInto() for zero-allocation rendering.
73730
+ * The `buffer` parameter is kept for backward-compatible signature but
73731
+ * is no longer used on this path — callers may pass any valid ImageData.
73557
73732
  */
73558
- renderSliceToCanvas(layer, axis, sliceIndex, buffer, targetCtx, scaledWidth, scaledHeight) {
73733
+ renderSliceToCanvas(layer, axis, sliceIndex, _buffer, targetCtx, scaledWidth, scaledHeight) {
73559
73734
  try {
73560
73735
  const volume = this.getVolumeForLayer(layer);
73561
73736
  if (!volume)
73562
73737
  return;
73563
- // Get channel visibility for this layer
73564
73738
  const channelVis = this.state.gui_states.layerChannel.channelVisibility[layer];
73565
- // Render label slice at full alpha — globalAlpha applied during compositeAllLayers
73566
- volume.renderLabelSliceInto(sliceIndex, axis, buffer, channelVis, 1.0);
73567
- this.setEmptyCanvasSize(axis);
73568
- this.state.protectedData.ctxes.emptyCtx.putImageData(buffer, 0, 0);
73739
+ const slice = volume.getSliceUint8(sliceIndex, axis);
73740
+ const W = slice.width;
73741
+ const H = slice.height;
73742
+ const stride = volume.getChannels();
73743
+ const labels = findLabelsInSlice(slice.data, W, H, stride, 0);
73744
+ if (labels.length === 0)
73745
+ return;
73746
+ targetCtx.save();
73747
+ // Vector fill — imageSmoothingEnabled is irrelevant here, but keep
73748
+ // it off to match the rest of the pipeline.
73569
73749
  targetCtx.imageSmoothingEnabled = false;
73570
- // Coronal (axis='y') Z-flip: vertically flip the rendered mask to match
73571
- // the Z-flip applied during the write path (syncLayerSliceData).
73750
+ // Coronal (axis='y') Z-flip: mirrors the flip applied by the write
73751
+ // path (syncLayerSliceData). Apply BEFORE the voxel→display scale
73752
+ // so the flip operates in display coordinates.
73572
73753
  if (axis === 'y') {
73573
- targetCtx.save();
73574
73754
  targetCtx.scale(1, -1);
73575
73755
  targetCtx.translate(0, -scaledHeight);
73576
73756
  }
73577
- targetCtx.drawImage(this.state.protectedData.canvases.emptyCanvas, 0, 0, scaledWidth, scaledHeight);
73578
- if (axis === 'y') {
73579
- targetCtx.restore();
73757
+ // Voxel coord (x ∈ [0, W], y ∈ [0, H]) → display coord.
73758
+ targetCtx.scale(scaledWidth / W, scaledHeight / H);
73759
+ for (const lbl of labels) {
73760
+ if (channelVis && channelVis[lbl] === false)
73761
+ continue;
73762
+ const color = volume.getChannelColor(lbl);
73763
+ const path = extractLabelContours(slice.data, W, H, lbl, stride, 0);
73764
+ targetCtx.fillStyle =
73765
+ `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a / 255})`;
73766
+ targetCtx.fill(path, 'nonzero');
73580
73767
  }
73768
+ targetCtx.restore();
73581
73769
  }
73582
- catch (err) {
73770
+ catch (_a) {
73583
73771
  // Slice out of bounds or volume not ready — skip silently
73584
73772
  }
73585
73773
  }
@@ -73664,6 +73852,11 @@ const DEFAULT_KEYBOARD_SETTINGS = {
73664
73852
  * Drawing tools that can be used with Shift+drag
73665
73853
  */
73666
73854
  const DRAWING_TOOLS = new Set(['pencil', 'brush', 'eraser']);
73855
+ /**
73856
+ * Sphere-family tools (single-placement sphere + brush/eraser variants).
73857
+ * All three participate in the same crosshair / contrast mutual-exclusion rules.
73858
+ */
73859
+ const SPHERE_TOOLS = new Set(['sphere', 'sphereBrush', 'sphereEraser']);
73667
73860
  class EventRouter {
73668
73861
  constructor(config) {
73669
73862
  // === State ===
@@ -73794,8 +73987,8 @@ class EventRouter {
73794
73987
  */
73795
73988
  setGuiTool(tool) {
73796
73989
  this.guiTool = tool;
73797
- // When entering sphere mode, keep crosshair if active, otherwise idle
73798
- if (tool === 'sphere') {
73990
+ // When entering any sphere-family tool, keep crosshair if active, otherwise idle
73991
+ if (SPHERE_TOOLS.has(tool)) {
73799
73992
  if (!this.state.crosshairEnabled) {
73800
73993
  this.setMode('idle');
73801
73994
  }
@@ -73807,10 +74000,12 @@ class EventRouter {
73807
74000
  * Blocked when draw or contrast mode is active, or left button is held (mutual exclusion).
73808
74001
  */
73809
74002
  toggleCrosshair() {
73810
- // Allow crosshair in drawing tools and sphere mode
73811
- if (!DRAWING_TOOLS.has(this.guiTool) && this.guiTool !== 'sphere')
74003
+ // Allow crosshair in drawing tools and all sphere-family tools (sphere / sphereBrush / sphereEraser)
74004
+ if (!DRAWING_TOOLS.has(this.guiTool) && !SPHERE_TOOLS.has(this.guiTool))
73812
74005
  return;
73813
- // Block crosshair activation during draw, contrast, or while left button held
74006
+ // Block crosshair activation during draw, contrast, or while left button held.
74007
+ // The leftButtonDown guard also enforces "once a sphere preview is on screen,
74008
+ // S is ignored until mouseup" for sphereBrush/sphereEraser.
73814
74009
  if (this.state.shiftHeld || this.state.leftButtonDown || this.mode === 'draw' || this.mode === 'contrast')
73815
74010
  return;
73816
74011
  this.state.crosshairEnabled = !this.state.crosshairEnabled;
@@ -73843,6 +74038,19 @@ class EventRouter {
73843
74038
  isRightButtonDown() {
73844
74039
  return this.state.rightButtonDown;
73845
74040
  }
74041
+ /**
74042
+ * Whether the user is currently performing a slice-drag (left-button held
74043
+ * in idle mode with a non-sphere tool). In this state the mouse wheel is
74044
+ * suppressed so that slice switching via drag and slice/zoom via wheel
74045
+ * cannot fight each other. Draw / contrast / crosshair set `mode` away
74046
+ * from 'idle', and sphere-family tools use left-drag for their own
74047
+ * purposes, so both are correctly excluded.
74048
+ */
74049
+ isDragSliceActive() {
74050
+ return (this.state.leftButtonDown &&
74051
+ this.mode === 'idle' &&
74052
+ !SPHERE_TOOLS.has(this.guiTool));
74053
+ }
73846
74054
  // ========================================
73847
74055
  // Configuration
73848
74056
  // ========================================
@@ -73937,9 +74145,9 @@ class EventRouter {
73937
74145
  }
73938
74146
  }
73939
74147
  if (this.contrastEnabled && this.keyboardSettings.contrast.includes(ev.key)) {
73940
- // Block contrast state when crosshair, draw, or sphere is active (mutual exclusion)
74148
+ // Block contrast state when crosshair, draw, or any sphere-family tool is active (mutual exclusion)
73941
74149
  if (!this.state.crosshairEnabled && this.mode !== 'draw'
73942
- && this.guiTool !== 'sphere') {
74150
+ && !SPHERE_TOOLS.has(this.guiTool)) {
73943
74151
  this.state.ctrlHeld = true;
73944
74152
  }
73945
74153
  }
@@ -74010,7 +74218,15 @@ class EventRouter {
74010
74218
  }
74011
74219
  }
74012
74220
  handleWheel(ev) {
74013
- // Route to external handler
74221
+ // Mutual exclusion: slice-drag wins over wheel. While the left button
74222
+ // is held in a slice-drag context, swallow wheel events so the image
74223
+ // cannot scroll/zoom at the same time the user is dragging slices.
74224
+ // Re-checked on every event, so pressing the left button mid-scroll
74225
+ // flips us into drag mode on the very next wheel tick.
74226
+ if (this.isDragSliceActive()) {
74227
+ ev.preventDefault();
74228
+ return;
74229
+ }
74014
74230
  if (this.wheelHandler) {
74015
74231
  this.wheelHandler(ev);
74016
74232
  }
@@ -74563,6 +74779,10 @@ class PanTool extends BaseTool {
74563
74779
  * Extracted from DrawToolCore.paintOnCanvas() closure (Phase 3).
74564
74780
  * Handles left-click drawing operations: pencil stroke + fill, brush strokes,
74565
74781
  * eraser, and undo snapshot capture/push.
74782
+ *
74783
+ * Phase B: Brush mode now writes voxels directly during mousemove and
74784
+ * re-renders via marching squares, eliminating the visual "snap" between
74785
+ * the smooth canvas stroke and the voxel-based post-release render.
74566
74786
  */
74567
74787
  class DrawingTool extends BaseTool {
74568
74788
  constructor(ctx, callbacks) {
@@ -74579,6 +74799,8 @@ class DrawingTool extends BaseTool {
74579
74799
  this.preDrawSlice = null;
74580
74800
  this.preDrawAxis = "z";
74581
74801
  this.preDrawSliceIndex = 0;
74802
+ /** Previous voxel-space position for Bresenham interpolation (brush mode) */
74803
+ this.prevVoxel = null;
74582
74804
  this.callbacks = callbacks;
74583
74805
  }
74584
74806
  /** Whether a draw operation is currently active */
@@ -74598,6 +74820,7 @@ class DrawingTool extends BaseTool {
74598
74820
  this.isPainting = false;
74599
74821
  this.drawingLines = [];
74600
74822
  this.clearArcFn = clearArcFn;
74823
+ this.prevVoxel = null;
74601
74824
  }
74602
74825
  /**
74603
74826
  * Called on left-click pointerdown in draw mode.
@@ -74625,6 +74848,22 @@ class DrawingTool extends BaseTool {
74625
74848
  this.ctx.nrrd_states.interaction.drawStartPos.y = e.offsetY;
74626
74849
  // Capture pre-draw slice snapshot for undo
74627
74850
  this.capturePreDrawSnapshot();
74851
+ // Brush mode: initialize voxel tracking and write first dot
74852
+ if (!this.ctx.gui_states.mode.pencil && !this.ctx.gui_states.mode.eraser) {
74853
+ const voxel = this.canvasToVoxel3D(e.offsetX, e.offsetY);
74854
+ this.prevVoxel = voxel;
74855
+ try {
74856
+ const vol = this.callbacks.getVolumeForLayer(this.ctx.gui_states.layerChannel.layer);
74857
+ const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
74858
+ const axis = this.ctx.protectedData.axis;
74859
+ const { rH, rV } = this.getVoxelBrushRadius();
74860
+ this.paintVoxelEllipse(vol, voxel, rH, rV, channel, axis);
74861
+ this.refreshLayerFromVolume();
74862
+ }
74863
+ catch (_a) {
74864
+ // Volume not ready
74865
+ }
74866
+ }
74628
74867
  }
74629
74868
  /**
74630
74869
  * Called on pointermove during an active drawing operation.
@@ -74638,10 +74877,16 @@ class DrawingTool extends BaseTool {
74638
74877
  this.ctx.nrrd_states.flags.stepClear = 1;
74639
74878
  (_a = this.clearArcFn) === null || _a === void 0 ? void 0 : _a.call(this, e.offsetX, e.offsetY, this.ctx.gui_states.drawing.brushAndEraserSize);
74640
74879
  }
74641
- else {
74880
+ else if (this.ctx.gui_states.mode.pencil) {
74881
+ // Pencil mode: accumulate points and draw visual lines on canvas
74642
74882
  this.drawingLines.push({ x: e.offsetX, y: e.offsetY });
74643
74883
  this.paintOnCanvasLayer(e.offsetX, e.offsetY);
74644
74884
  }
74885
+ else {
74886
+ // Brush mode: write voxels directly + re-render via marching squares
74887
+ this.drawingLines.push({ x: e.offsetX, y: e.offsetY });
74888
+ this.paintBrushVoxelMove(e.offsetX, e.offsetY);
74889
+ }
74645
74890
  }
74646
74891
  }
74647
74892
  /**
@@ -74654,11 +74899,9 @@ class DrawingTool extends BaseTool {
74654
74899
  ctx.closePath();
74655
74900
  if (!this.ctx.gui_states.mode.eraser) {
74656
74901
  if (this.ctx.gui_states.mode.pencil) {
74657
- // Clear only the current layer canvas (NOT master)
74902
+ // Pencil mode: fill polygon then bake to voxels
74658
74903
  canvas.width = canvas.width;
74659
- // Redraw previous layer data from volume
74660
74904
  this.redrawPreviousImageToLayerCtx(ctx);
74661
- // Draw new pencil strokes on current layer canvas
74662
74905
  ctx.beginPath();
74663
74906
  ctx.moveTo(this.drawingLines[0].x, this.drawingLines[0].y);
74664
74907
  for (let i = 1; i < this.drawingLines.length; i++) {
@@ -74668,12 +74911,26 @@ class DrawingTool extends BaseTool {
74668
74911
  ctx.lineWidth = 1;
74669
74912
  ctx.fillStyle = this.ctx.gui_states.drawing.fillColor;
74670
74913
  ctx.fill();
74671
- // Composite ALL layers to master (not just current layer)
74672
74914
  this.callbacks.compositeAllLayers();
74915
+ // Pencil still needs canvas→voxel bake
74916
+ this.callbacks.syncLayerSliceData(this.ctx.nrrd_states.view.currentSliceIndex, this.ctx.gui_states.layerChannel.layer);
74917
+ // Re-render from voxels to eliminate pencil snap
74918
+ this.refreshLayerFromVolume();
74673
74919
  }
74920
+ else {
74921
+ // Brush mode: voxels already written during mousemove
74922
+ // Just re-render final state and composite
74923
+ this.refreshLayerFromVolume();
74924
+ }
74925
+ }
74926
+ else {
74927
+ // Eraser mode: still needs canvas→voxel bake
74928
+ this.callbacks.syncLayerSliceData(this.ctx.nrrd_states.view.currentSliceIndex, this.ctx.gui_states.layerChannel.layer);
74929
+ // Re-render from voxels for eraser too
74930
+ this.refreshLayerFromVolume();
74674
74931
  }
74675
- this.callbacks.syncLayerSliceData(this.ctx.nrrd_states.view.currentSliceIndex, this.ctx.gui_states.layerChannel.layer);
74676
74932
  this.isPainting = false;
74933
+ this.prevVoxel = null;
74677
74934
  // Push undo delta
74678
74935
  this.pushUndoDelta();
74679
74936
  }
@@ -74684,6 +74941,7 @@ class DrawingTool extends BaseTool {
74684
74941
  */
74685
74942
  onPointerLeave() {
74686
74943
  this.isPainting = false;
74944
+ this.prevVoxel = null;
74687
74945
  if (!this.leftClicked)
74688
74946
  return false;
74689
74947
  this.leftClicked = false;
@@ -74694,11 +74952,6 @@ class DrawingTool extends BaseTool {
74694
74952
  /**
74695
74953
  * Create a self-managing mouseover/mouseout/mousemove handler
74696
74954
  * that tracks brush hover position for the preview circle.
74697
- *
74698
- * The returned function should be registered on drawingCanvas for
74699
- * "mouseover" and "mouseout" events. It adds/removes a "mousemove"
74700
- * listener on itself to keep mouseOverX/Y up-to-date while the
74701
- * cursor is inside the canvas.
74702
74955
  */
74703
74956
  createBrushTrackingHandler() {
74704
74957
  const handler = (e) => {
@@ -74722,8 +74975,6 @@ class DrawingTool extends BaseTool {
74722
74975
  }
74723
74976
  /**
74724
74977
  * Render brush circle preview on the drawing context.
74725
- * Called from the start() render loop when in draw mode and not
74726
- * actively painting. Skipped in pencil/eraser mode.
74727
74978
  */
74728
74979
  renderBrushPreview(ctx, width, height) {
74729
74980
  if (this.ctx.gui_states.mode.pencil ||
@@ -74738,6 +74989,195 @@ class DrawingTool extends BaseTool {
74738
74989
  ctx.strokeStyle = this.ctx.gui_states.drawing.brushColor;
74739
74990
  ctx.stroke();
74740
74991
  }
74992
+ // ── Brush mode: direct voxel write ────────────────────────────
74993
+ /**
74994
+ * Convert display (zoomed) coordinates to 3D voxel coordinates.
74995
+ * Same logic as SphereBrushTool.canvasToVoxelCenter.
74996
+ */
74997
+ canvasToVoxel3D(displayX, displayY) {
74998
+ const nrrd = this.ctx.nrrd_states;
74999
+ const axis = this.ctx.protectedData.axis;
75000
+ const cw = nrrd.view.changedWidth;
75001
+ const ch = nrrd.view.changedHeight;
75002
+ const sliceIndex = nrrd.view.currentSliceIndex;
75003
+ switch (axis) {
75004
+ case 'z':
75005
+ return {
75006
+ x: displayX * nrrd.image.nrrd_x_pixel / cw,
75007
+ y: displayY * nrrd.image.nrrd_y_pixel / ch,
75008
+ z: sliceIndex,
75009
+ };
75010
+ case 'y':
75011
+ return {
75012
+ x: displayX * nrrd.image.nrrd_x_pixel / cw,
75013
+ y: sliceIndex,
75014
+ z: (ch - displayY) * nrrd.image.nrrd_z_pixel / ch,
75015
+ };
75016
+ case 'x':
75017
+ return {
75018
+ x: sliceIndex,
75019
+ y: displayY * nrrd.image.nrrd_y_pixel / ch,
75020
+ z: displayX * nrrd.image.nrrd_z_pixel / cw,
75021
+ };
75022
+ }
75023
+ }
75024
+ /**
75025
+ * Get voxel-space brush radius for visible axes.
75026
+ * Converts display-pixel brush size to voxel units.
75027
+ */
75028
+ getVoxelBrushRadius() {
75029
+ const nrrd = this.ctx.nrrd_states;
75030
+ const axis = this.ctx.protectedData.axis;
75031
+ const cw = nrrd.view.changedWidth;
75032
+ const ch = nrrd.view.changedHeight;
75033
+ const displayRadius = this.ctx.gui_states.drawing.brushAndEraserSize / 2;
75034
+ switch (axis) {
75035
+ case 'z':
75036
+ return {
75037
+ rH: displayRadius * nrrd.image.nrrd_x_pixel / cw,
75038
+ rV: displayRadius * nrrd.image.nrrd_y_pixel / ch,
75039
+ };
75040
+ case 'y':
75041
+ return {
75042
+ rH: displayRadius * nrrd.image.nrrd_x_pixel / cw,
75043
+ rV: displayRadius * nrrd.image.nrrd_z_pixel / ch,
75044
+ };
75045
+ case 'x':
75046
+ return {
75047
+ rH: displayRadius * nrrd.image.nrrd_z_pixel / cw,
75048
+ rV: displayRadius * nrrd.image.nrrd_y_pixel / ch,
75049
+ };
75050
+ }
75051
+ }
75052
+ /**
75053
+ * Paint a 2D ellipse of voxels on the current slice.
75054
+ * Only the two visible axes are iterated; the slice-direction axis is fixed.
75055
+ */
75056
+ paintVoxelEllipse(vol, center, rH, rV, label, axis) {
75057
+ const dims = vol.getDimensions();
75058
+ switch (axis) {
75059
+ case 'z': {
75060
+ const minX = Math.max(0, Math.floor(center.x - rH));
75061
+ const maxX = Math.min(dims.width - 1, Math.ceil(center.x + rH));
75062
+ const minY = Math.max(0, Math.floor(center.y - rV));
75063
+ const maxY = Math.min(dims.height - 1, Math.ceil(center.y + rV));
75064
+ const z = Math.round(center.z);
75065
+ for (let y = minY; y <= maxY; y++) {
75066
+ for (let x = minX; x <= maxX; x++) {
75067
+ const dx = rH > 0 ? (x - center.x) / rH : 0;
75068
+ const dy = rV > 0 ? (y - center.y) / rV : 0;
75069
+ if (dx * dx + dy * dy <= 1.0) {
75070
+ vol.setVoxel(x, y, z, label);
75071
+ }
75072
+ }
75073
+ }
75074
+ break;
75075
+ }
75076
+ case 'y': {
75077
+ const minX = Math.max(0, Math.floor(center.x - rH));
75078
+ const maxX = Math.min(dims.width - 1, Math.ceil(center.x + rH));
75079
+ const minZ = Math.max(0, Math.floor(center.z - rV));
75080
+ const maxZ = Math.min(dims.depth - 1, Math.ceil(center.z + rV));
75081
+ const y = Math.round(center.y);
75082
+ for (let z = minZ; z <= maxZ; z++) {
75083
+ for (let x = minX; x <= maxX; x++) {
75084
+ const dx = rH > 0 ? (x - center.x) / rH : 0;
75085
+ const dz = rV > 0 ? (z - center.z) / rV : 0;
75086
+ if (dx * dx + dz * dz <= 1.0) {
75087
+ vol.setVoxel(x, y, z, label);
75088
+ }
75089
+ }
75090
+ }
75091
+ break;
75092
+ }
75093
+ case 'x': {
75094
+ const minZ = Math.max(0, Math.floor(center.z - rH));
75095
+ const maxZ = Math.min(dims.depth - 1, Math.ceil(center.z + rH));
75096
+ const minY = Math.max(0, Math.floor(center.y - rV));
75097
+ const maxY = Math.min(dims.height - 1, Math.ceil(center.y + rV));
75098
+ const x = Math.round(center.x);
75099
+ for (let yy = minY; yy <= maxY; yy++) {
75100
+ for (let zz = minZ; zz <= maxZ; zz++) {
75101
+ const dz = rH > 0 ? (zz - center.z) / rH : 0;
75102
+ const dy = rV > 0 ? (yy - center.y) / rV : 0;
75103
+ if (dz * dz + dy * dy <= 1.0) {
75104
+ vol.setVoxel(x, yy, zz, label);
75105
+ }
75106
+ }
75107
+ }
75108
+ break;
75109
+ }
75110
+ }
75111
+ }
75112
+ /**
75113
+ * Brush mode mousemove: write voxels along stroke segment, then re-render.
75114
+ * Uses linear interpolation between prev and current positions to ensure
75115
+ * no gaps when the mouse moves fast.
75116
+ */
75117
+ paintBrushVoxelMove(displayX, displayY) {
75118
+ try {
75119
+ const vol = this.callbacks.getVolumeForLayer(this.ctx.gui_states.layerChannel.layer);
75120
+ const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
75121
+ const axis = this.ctx.protectedData.axis;
75122
+ const { rH, rV } = this.getVoxelBrushRadius();
75123
+ const current = this.canvasToVoxel3D(displayX, displayY);
75124
+ if (this.prevVoxel) {
75125
+ // Interpolate from prev to current to fill gaps
75126
+ const prev = this.prevVoxel;
75127
+ let steps;
75128
+ switch (axis) {
75129
+ case 'z':
75130
+ steps = Math.max(Math.abs(current.x - prev.x), Math.abs(current.y - prev.y));
75131
+ break;
75132
+ case 'y':
75133
+ steps = Math.max(Math.abs(current.x - prev.x), Math.abs(current.z - prev.z));
75134
+ break;
75135
+ case 'x':
75136
+ steps = Math.max(Math.abs(current.z - prev.z), Math.abs(current.y - prev.y));
75137
+ break;
75138
+ }
75139
+ steps = Math.max(1, Math.ceil(steps));
75140
+ for (let i = 0; i <= steps; i++) {
75141
+ const t = steps === 0 ? 0 : i / steps;
75142
+ const pt = {
75143
+ x: prev.x + (current.x - prev.x) * t,
75144
+ y: prev.y + (current.y - prev.y) * t,
75145
+ z: prev.z + (current.z - prev.z) * t,
75146
+ };
75147
+ this.paintVoxelEllipse(vol, pt, rH, rV, channel, axis);
75148
+ }
75149
+ }
75150
+ else {
75151
+ this.paintVoxelEllipse(vol, current, rH, rV, channel, axis);
75152
+ }
75153
+ this.prevVoxel = current;
75154
+ // Re-render the layer canvas from volume (marching squares)
75155
+ this.refreshLayerFromVolume();
75156
+ }
75157
+ catch (_a) {
75158
+ // Volume not ready — fall back to canvas drawing
75159
+ this.paintOnCanvasLayer(displayX, displayY);
75160
+ }
75161
+ }
75162
+ /**
75163
+ * Re-render the active layer's canvas from MaskVolume via marching squares,
75164
+ * then composite all layers to master.
75165
+ */
75166
+ refreshLayerFromVolume() {
75167
+ const layer = this.ctx.gui_states.layerChannel.layer;
75168
+ const target = this.ctx.protectedData.layerTargets.get(layer);
75169
+ if (!target)
75170
+ return;
75171
+ const { ctx, canvas } = target;
75172
+ canvas.width = canvas.width; // clear
75173
+ const axis = this.ctx.protectedData.axis;
75174
+ const buffer = this.callbacks.getOrCreateSliceBuffer(axis);
75175
+ if (buffer) {
75176
+ 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);
75177
+ }
75178
+ this.callbacks.compositeAllLayers();
75179
+ this.ctx.protectedData.mainPreSlices.mesh.material.map.needsUpdate = true;
75180
+ }
74741
75181
  // ── Private helpers ────────────────────────────────────────
74742
75182
  /** Capture pre-draw slice snapshot for undo */
74743
75183
  capturePreDrawSnapshot() {
@@ -74774,27 +75214,18 @@ class DrawingTool extends BaseTool {
74774
75214
  }
74775
75215
  /**
74776
75216
  * Redraws persisted layer data onto ctx before new pencil fill.
74777
- * Extracted from DrawToolCore.
75217
+ * Delegates to the host's vector renderSliceToCanvas for consistency.
74778
75218
  */
74779
75219
  redrawPreviousImageToLayerCtx(ctx) {
74780
- var _a;
74781
- const tempPreImg = (_a = this.callbacks.filterDrawedImage(this.ctx.protectedData.axis, this.ctx.nrrd_states.view.currentSliceIndex)) === null || _a === void 0 ? void 0 : _a.image;
74782
- this.ctx.protectedData.canvases.emptyCanvas.width =
74783
- this.ctx.protectedData.canvases.emptyCanvas.width;
74784
- this.ctx.protectedData.ctxes.emptyCtx.putImageData(tempPreImg, 0, 0);
74785
- ctx.imageSmoothingEnabled = false;
74786
- // Coronal (axis='y') Z-flip: same as renderSliceToCanvas.
74787
- if (this.ctx.protectedData.axis === 'y') {
74788
- ctx.save();
74789
- ctx.scale(1, -1);
74790
- ctx.translate(0, -this.ctx.nrrd_states.view.changedHeight);
74791
- }
74792
- ctx.drawImage(this.ctx.protectedData.canvases.emptyCanvas, 0, 0, this.ctx.nrrd_states.view.changedWidth, this.ctx.nrrd_states.view.changedHeight);
74793
- if (this.ctx.protectedData.axis === 'y') {
74794
- ctx.restore();
74795
- }
75220
+ const axis = this.ctx.protectedData.axis;
75221
+ const W = this.ctx.nrrd_states.view.changedWidth;
75222
+ const H = this.ctx.nrrd_states.view.changedHeight;
75223
+ const buffer = this.callbacks.getOrCreateSliceBuffer(axis);
75224
+ if (!buffer)
75225
+ return;
75226
+ this.callbacks.renderSliceToCanvas(this.ctx.gui_states.layerChannel.layer, axis, this.ctx.nrrd_states.view.currentSliceIndex, buffer, ctx, W, H);
74796
75227
  }
74797
- /** Draw a line segment on a layer canvas */
75228
+ /** Draw a line segment on a layer canvas (pencil mode only) */
74798
75229
  drawLinesOnLayer(ctx, x, y) {
74799
75230
  ctx.beginPath();
74800
75231
  ctx.moveTo(this.ctx.nrrd_states.interaction.drawStartPos.x, this.ctx.nrrd_states.interaction.drawStartPos.y);
@@ -74810,17 +75241,13 @@ class DrawingTool extends BaseTool {
74810
75241
  ctx.stroke();
74811
75242
  ctx.closePath();
74812
75243
  }
74813
- /** Paint a segment on the current layer canvas and composite to master */
75244
+ /** Paint a segment on the current layer canvas and composite to master (pencil mode) */
74814
75245
  paintOnCanvasLayer(x, y) {
74815
75246
  const { ctx } = this.callbacks.setCurrentLayer();
74816
- // Draw only on the current layer canvas (not master directly)
74817
75247
  this.drawLinesOnLayer(ctx, x, y);
74818
- // Composite all layers to master to preserve other layers' data
74819
75248
  this.callbacks.compositeAllLayers();
74820
- // Reset drawing start position to current position
74821
75249
  this.ctx.nrrd_states.interaction.drawStartPos.x = x;
74822
75250
  this.ctx.nrrd_states.interaction.drawStartPos.y = y;
74823
- // Flag the map as needing updating
74824
75251
  this.ctx.protectedData.mainPreSlices.mesh.material.map.needsUpdate = true;
74825
75252
  }
74826
75253
  }
@@ -74960,7 +75387,11 @@ class ImageStoreHelper extends BaseTool {
74960
75387
  class SphereBrushTool extends BaseTool {
74961
75388
  constructor(ctx, callbacks) {
74962
75389
  super(ctx);
74963
- /** Recorded sphere center in canvas mm-space */
75390
+ /**
75391
+ * Recorded sphere center in display (zoomed) coordinates.
75392
+ * Used for both preview redraw and voxel calculation — the voxel conversion
75393
+ * uses `changedWidth/Height` as the display scale, so no mm detour is needed.
75394
+ */
74964
75395
  this.centerX = 0;
74965
75396
  this.centerY = 0;
74966
75397
  this.centerSlice = 0;
@@ -74968,10 +75399,24 @@ class SphereBrushTool extends BaseTool {
74968
75399
  this.active = false;
74969
75400
  /** Current operation mode for the active placement */
74970
75401
  this.mode = "brush";
74971
- /** Cumulative "before" snapshots for drag-erase undo (z-index → slice data) */
75402
+ /** Cumulative "before" snapshots for drag undo (z-index → slice data) — used by both brush and eraser drag */
74972
75403
  this.dragBeforeSnapshots = new Map();
74973
- /** Whether a drag-erase has actually moved (vs. simple click-release) */
75404
+ /** Whether a drag has actually moved (vs. simple click-release) */
74974
75405
  this.dragMoved = false;
75406
+ /**
75407
+ * Latest cursor position in display (zoomed) coords. Used by the wheel
75408
+ * handler to redraw the preview at the cursor, not the original mousedown
75409
+ * point, during mid-drag radius changes.
75410
+ */
75411
+ this.lastDisplayX = 0;
75412
+ this.lastDisplayY = 0;
75413
+ this.lastSliceIndex = 0;
75414
+ /**
75415
+ * All sphere centers written during the current stroke. The wheel handler
75416
+ * retroactively re-renders the entire stroke at the new radius by restoring
75417
+ * `dragBeforeSnapshots` and replaying every entry here.
75418
+ */
75419
+ this.strokeCenters = [];
74975
75420
  this.callbacks = callbacks;
74976
75421
  }
74977
75422
  setCallbacks(callbacks) {
@@ -74979,41 +75424,66 @@ class SphereBrushTool extends BaseTool {
74979
75424
  }
74980
75425
  // ── Geometry (ported from SphereTool) ──────────────────────────
74981
75426
  /**
74982
- * Convert canvas mm-space coordinates to 3D voxel coordinates.
75427
+ * Convert display (zoomed) coordinates to 3D voxel coordinates.
75428
+ *
75429
+ * Uses `changedWidth/Height` as the display scale directly (instead of
75430
+ * going through mm via `/sizeFactor`). This keeps the write pixel-exact
75431
+ * with the preview circle, which is drawn on a `changedWidth x changedHeight`
75432
+ * sized sphere canvas — avoiding floating-point drift at high zoom levels.
74983
75433
  */
74984
- canvasToVoxelCenter(canvasX, canvasY, sliceIndex, axis) {
75434
+ canvasToVoxelCenter(displayX, displayY, sliceIndex, axis) {
74985
75435
  const nrrd = this.ctx.nrrd_states;
75436
+ const cw = nrrd.view.changedWidth;
75437
+ const ch = nrrd.view.changedHeight;
74986
75438
  switch (axis) {
74987
75439
  case 'z':
74988
75440
  return {
74989
- x: canvasX * nrrd.image.nrrd_x_pixel / nrrd.image.nrrd_x_mm,
74990
- y: canvasY * nrrd.image.nrrd_y_pixel / nrrd.image.nrrd_y_mm,
75441
+ x: displayX * nrrd.image.nrrd_x_pixel / cw,
75442
+ y: displayY * nrrd.image.nrrd_y_pixel / ch,
74991
75443
  z: sliceIndex,
74992
75444
  };
74993
75445
  case 'y':
74994
75446
  return {
74995
- x: canvasX * nrrd.image.nrrd_x_pixel / nrrd.image.nrrd_x_mm,
75447
+ x: displayX * nrrd.image.nrrd_x_pixel / cw,
74996
75448
  y: sliceIndex,
74997
- z: (nrrd.image.nrrd_z_mm - canvasY) * nrrd.image.nrrd_z_pixel / nrrd.image.nrrd_z_mm,
75449
+ // Coronal vertical flip: display Y=0 is top, voxel Z increases downward in mm-space
75450
+ z: (ch - displayY) * nrrd.image.nrrd_z_pixel / ch,
74998
75451
  };
74999
75452
  case 'x':
75000
75453
  return {
75001
75454
  x: sliceIndex,
75002
- y: canvasY * nrrd.image.nrrd_y_pixel / nrrd.image.nrrd_y_mm,
75003
- z: canvasX * nrrd.image.nrrd_z_pixel / nrrd.image.nrrd_z_mm,
75455
+ y: displayY * nrrd.image.nrrd_y_pixel / ch,
75456
+ z: displayX * nrrd.image.nrrd_z_pixel / cw,
75004
75457
  };
75005
75458
  }
75006
75459
  }
75007
75460
  /**
75008
75461
  * Convert mm radius to per-axis voxel radii.
75462
+ *
75463
+ * For the two axes visible in the current view, scale via
75464
+ * `changedWidth/Height` so the rendered ellipsoid cross-section matches
75465
+ * the preview pixel radius exactly. The third (slice-direction) axis is
75466
+ * not visible, so it uses the plain mm-based ratio.
75009
75467
  */
75010
- getVoxelRadii(radius) {
75468
+ getVoxelRadii(radius, axis) {
75011
75469
  const nrrd = this.ctx.nrrd_states;
75012
- return {
75013
- rx: radius * nrrd.image.nrrd_x_pixel / nrrd.image.nrrd_x_mm,
75014
- ry: radius * nrrd.image.nrrd_y_pixel / nrrd.image.nrrd_y_mm,
75015
- rz: radius * nrrd.image.nrrd_z_pixel / nrrd.image.nrrd_z_mm,
75016
- };
75470
+ const sf = nrrd.view.sizeFactor;
75471
+ const cw = nrrd.view.changedWidth;
75472
+ const ch = nrrd.view.changedHeight;
75473
+ // pixel-exact voxel radius for the visible horizontal/vertical dims
75474
+ const rxView = radius * sf * nrrd.image.nrrd_x_pixel / cw;
75475
+ const ryView = radius * sf * nrrd.image.nrrd_y_pixel / ch;
75476
+ const rzHoriz = radius * sf * nrrd.image.nrrd_z_pixel / cw;
75477
+ const rzVert = radius * sf * nrrd.image.nrrd_z_pixel / ch;
75478
+ // mm-based fallback for the slice-direction (perpendicular) dim
75479
+ const rxMm = radius * nrrd.image.nrrd_x_pixel / nrrd.image.nrrd_x_mm;
75480
+ const ryMm = radius * nrrd.image.nrrd_y_pixel / nrrd.image.nrrd_y_mm;
75481
+ const rzMm = radius * nrrd.image.nrrd_z_pixel / nrrd.image.nrrd_z_mm;
75482
+ switch (axis) {
75483
+ case 'z': return { rx: rxView, ry: ryView, rz: rzMm };
75484
+ case 'y': return { rx: rxView, ry: ryMm, rz: rzVert };
75485
+ case 'x': return { rx: rxMm, ry: ryView, rz: rzHoriz };
75486
+ }
75017
75487
  }
75018
75488
  /**
75019
75489
  * Compute clamped bounding box for an ellipsoid in voxel space.
@@ -75118,11 +75588,15 @@ class SphereBrushTool extends BaseTool {
75118
75588
  drawPreview(mouseX, mouseY, radius, isEraser) {
75119
75589
  const sphereCanvas = this.ctx.protectedData.canvases.drawingSphereCanvas;
75120
75590
  const sphereCtx = this.ctx.protectedData.ctxes.drawingSphereCtx;
75121
- // Clear and resize sphere canvas
75122
- sphereCanvas.width = this.ctx.protectedData.canvases.originCanvas.width;
75123
- sphereCanvas.height = this.ctx.protectedData.canvases.originCanvas.height;
75591
+ // Size sphere canvas to the zoomed display size so start() draws it 1:1 —
75592
+ // no scaling step, eliminating any zoom-dependent positioning error.
75593
+ const sf = this.ctx.nrrd_states.view.sizeFactor;
75594
+ sphereCanvas.width = this.ctx.nrrd_states.view.changedWidth;
75595
+ sphereCanvas.height = this.ctx.nrrd_states.view.changedHeight;
75596
+ // mouseX/mouseY are already in zoomed display coords; scale radius to match.
75597
+ const scaledRadius = radius * sf;
75124
75598
  sphereCtx.beginPath();
75125
- sphereCtx.arc(mouseX, mouseY, radius, 0, 2 * Math.PI);
75599
+ sphereCtx.arc(mouseX, mouseY, scaledRadius, 0, 2 * Math.PI);
75126
75600
  if (isEraser) {
75127
75601
  // Eraser preview: dashed outline
75128
75602
  sphereCtx.strokeStyle = "#ff4444";
@@ -75175,38 +75649,135 @@ class SphereBrushTool extends BaseTool {
75175
75649
  return (e) => {
75176
75650
  e.preventDefault();
75177
75651
  const sphere = this.ctx.nrrd_states.sphere;
75178
- if (e.deltaY < 0) {
75179
- sphere.sphereBrushRadius += 1;
75652
+ sphere.sphereBrushRadius = Math.max(1, Math.min(sphere.sphereBrushRadius + (e.deltaY < 0 ? 1 : -1), 50));
75653
+ // 3D Slicer-style retroactive resize: if the user has already started
75654
+ // painting/erasing a stroke, re-render the entire stroke at the new
75655
+ // radius so the whole pipe/erase-path resizes together.
75656
+ if (this.active && this.dragMoved && this.strokeCenters.length > 0) {
75657
+ this.replayStrokeWithCurrentRadius();
75180
75658
  }
75181
- else {
75182
- sphere.sphereBrushRadius -= 1;
75183
- }
75184
- sphere.sphereBrushRadius = Math.max(1, Math.min(sphere.sphereBrushRadius, 50));
75185
- // Redraw preview
75659
+ // Preview always follows the latest cursor position, not the mousedown
75660
+ // point — otherwise the preview circle appears stuck on click-down when
75661
+ // the user scrolls after dragging elsewhere.
75186
75662
  if (this.active) {
75187
- this.drawPreview(this.centerX, this.centerY, sphere.sphereBrushRadius, this.mode === "eraser");
75663
+ this.drawPreview(this.lastDisplayX, this.lastDisplayY, sphere.sphereBrushRadius, this.mode === "eraser");
75188
75664
  }
75189
75665
  };
75190
75666
  }
75667
+ /**
75668
+ * Retroactively re-render the current stroke at the current radius.
75669
+ *
75670
+ * Step 1: walk every recorded stroke center and expand
75671
+ * `dragBeforeSnapshots` to cover the new (larger) bounding box. Slices
75672
+ * newly included here are still pristine because the original stroke
75673
+ * never touched them.
75674
+ * Step 2: restore every snapshotted slice from its pristine backup.
75675
+ * Step 3: replay every stroke center at the new radius.
75676
+ *
75677
+ * Step 3 produces the same final state as if the user had used the new
75678
+ * radius from the start. Undo still captures the full stroke as one group
75679
+ * at pointerup.
75680
+ */
75681
+ replayStrokeWithCurrentRadius() {
75682
+ if (this.strokeCenters.length === 0)
75683
+ return;
75684
+ const layer = this.ctx.gui_states.layerChannel.layer;
75685
+ const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
75686
+ const axis = this.ctx.protectedData.axis;
75687
+ const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
75688
+ let vol;
75689
+ try {
75690
+ vol = this.callbacks.getVolumeForLayer(layer);
75691
+ }
75692
+ catch (_a) {
75693
+ return;
75694
+ }
75695
+ const dims = vol.getDimensions();
75696
+ // 1. Expand snapshot coverage using the new radius so any newly-exposed
75697
+ // Z slices are captured while still pristine.
75698
+ for (const sc of this.strokeCenters) {
75699
+ const center = this.canvasToVoxelCenter(sc.x, sc.y, sc.sliceIndex, axis);
75700
+ const radii = this.getVoxelRadii(radius, axis);
75701
+ const bb = this.getBoundingBox(center, radii, dims);
75702
+ this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
75703
+ }
75704
+ // 2. Restore every snapshotted slice from pristine backup.
75705
+ for (const [z, pristine] of this.dragBeforeSnapshots) {
75706
+ try {
75707
+ vol.setSliceUint8(z, pristine, 'z');
75708
+ }
75709
+ catch (_b) {
75710
+ // Slice write failed — skip silently so a single bad slice doesn't break the replay.
75711
+ }
75712
+ }
75713
+ // 3. Replay every stroke center with the new radius.
75714
+ for (const sc of this.strokeCenters) {
75715
+ const center = this.canvasToVoxelCenter(sc.x, sc.y, sc.sliceIndex, axis);
75716
+ const radii = this.getVoxelRadii(radius, axis);
75717
+ const bb = this.getBoundingBox(center, radii, dims);
75718
+ if (this.mode === "brush") {
75719
+ this.write3DSphereToBrush(vol, center, radii, bb, channel);
75720
+ }
75721
+ else {
75722
+ this.erase3DSphereFromVolume(vol, center, radii, bb, channel);
75723
+ }
75724
+ }
75725
+ this.refreshDisplay(layer, undefined, false);
75726
+ }
75191
75727
  // ── Sphere Brush Click/PointerUp ───────────────────────────────
75192
75728
  /**
75193
- * Handle pointer-down in sphereBrush mode (direct click, no Shift needed).
75729
+ * Handle pointer-down in sphereBrush mode (3D Slicer-style).
75730
+ *
75731
+ * Captures pristine snapshots at the mousedown location but does NOT
75732
+ * write any voxels yet — the user may wheel-adjust the radius before
75733
+ * dragging. The first sphere is written either by the first pointermove
75734
+ * or by pointerup (single-click fallback).
75194
75735
  */
75195
75736
  onSphereBrushClick(e) {
75196
- this.centerX = e.offsetX / this.ctx.nrrd_states.view.sizeFactor;
75197
- this.centerY = e.offsetY / this.ctx.nrrd_states.view.sizeFactor;
75737
+ this.centerX = e.offsetX;
75738
+ this.centerY = e.offsetY;
75198
75739
  this.centerSlice = this.ctx.nrrd_states.view.currentSliceIndex;
75740
+ this.lastDisplayX = this.centerX;
75741
+ this.lastDisplayY = this.centerY;
75742
+ this.lastSliceIndex = this.centerSlice;
75199
75743
  this.active = true;
75200
75744
  this.mode = "brush";
75201
- this.drawPreview(this.centerX, this.centerY, this.ctx.nrrd_states.sphere.sphereBrushRadius, false);
75745
+ this.dragMoved = false;
75746
+ this.dragBeforeSnapshots.clear();
75747
+ this.strokeCenters = [{ x: this.centerX, y: this.centerY, sliceIndex: this.centerSlice }];
75748
+ const layer = this.ctx.gui_states.layerChannel.layer;
75749
+ const axis = this.ctx.protectedData.axis;
75750
+ const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
75751
+ try {
75752
+ const vol = this.callbacks.getVolumeForLayer(layer);
75753
+ const dims = vol.getDimensions();
75754
+ const center = this.canvasToVoxelCenter(this.centerX, this.centerY, this.centerSlice, axis);
75755
+ const radii = this.getVoxelRadii(radius, axis);
75756
+ const bb = this.getBoundingBox(center, radii, dims);
75757
+ this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
75758
+ }
75759
+ catch (_a) {
75760
+ // Volume not ready — preview still shown so user gets visual feedback
75761
+ }
75762
+ this.drawPreview(this.centerX, this.centerY, radius, false);
75202
75763
  }
75203
75764
  /**
75204
- * Handle pointer-up in sphereBrush mode — write sphere to volume.
75765
+ * Handle pointer-move in sphereBrush mode — drag to continuously paint.
75766
+ *
75767
+ * Mirrors sphereEraser drag: writes a new sphere at each move position,
75768
+ * extends the cumulative before-snapshot to cover any newly-touched Z
75769
+ * slices, and repaints the preview. Never notifies the backend; pointerup
75770
+ * does that once for the whole stroke.
75205
75771
  */
75206
- onSphereBrushPointerUp() {
75772
+ onSphereBrushMove(e) {
75207
75773
  if (!this.active || this.mode !== "brush")
75208
75774
  return;
75209
- this.active = false;
75775
+ this.dragMoved = true;
75776
+ const sliceIndex = this.ctx.nrrd_states.view.currentSliceIndex;
75777
+ this.lastDisplayX = e.offsetX;
75778
+ this.lastDisplayY = e.offsetY;
75779
+ this.lastSliceIndex = sliceIndex;
75780
+ this.strokeCenters.push({ x: e.offsetX, y: e.offsetY, sliceIndex });
75210
75781
  const layer = this.ctx.gui_states.layerChannel.layer;
75211
75782
  const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
75212
75783
  const axis = this.ctx.protectedData.axis;
@@ -75216,26 +75787,56 @@ class SphereBrushTool extends BaseTool {
75216
75787
  vol = this.callbacks.getVolumeForLayer(layer);
75217
75788
  }
75218
75789
  catch (_a) {
75219
- this.clearPreview();
75220
75790
  return;
75221
75791
  }
75222
75792
  const dims = vol.getDimensions();
75223
- const center = this.canvasToVoxelCenter(this.centerX, this.centerY, this.centerSlice, axis);
75224
- const radii = this.getVoxelRadii(radius);
75793
+ const center = this.canvasToVoxelCenter(e.offsetX, e.offsetY, sliceIndex, axis);
75794
+ const radii = this.getVoxelRadii(radius, axis);
75225
75795
  const bb = this.getBoundingBox(center, radii, dims);
75226
- // Capture pre-write snapshots
75227
- const before = this.captureSliceSnapshots(vol, bb.minZ, bb.maxZ);
75228
- // Write sphere
75796
+ this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
75229
75797
  this.write3DSphereToBrush(vol, center, radii, bb, channel);
75230
- // Capture post-write snapshots and push undo group
75231
- const after = this.captureSliceSnapshots(vol, bb.minZ, bb.maxZ);
75232
- const deltas = this.buildUndoGroup(layer, before, after);
75798
+ this.drawPreview(e.offsetX, e.offsetY, radius, false);
75799
+ this.refreshDisplay(layer, undefined, false);
75800
+ }
75801
+ /**
75802
+ * Handle pointer-up in sphereBrush mode — finalize stroke + push undo group
75803
+ * + single batched backend notify for all Z slices touched during the drag.
75804
+ */
75805
+ onSphereBrushPointerUp() {
75806
+ if (!this.active || this.mode !== "brush")
75807
+ return;
75808
+ this.active = false;
75809
+ const layer = this.ctx.gui_states.layerChannel.layer;
75810
+ const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
75811
+ const axis = this.ctx.protectedData.axis;
75812
+ const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
75813
+ let vol;
75814
+ try {
75815
+ vol = this.callbacks.getVolumeForLayer(layer);
75816
+ }
75817
+ catch (_a) {
75818
+ this.finalizeStrokeState();
75819
+ return;
75820
+ }
75821
+ // Click-without-drag: write the single sphere now so a plain click still
75822
+ // paints something. onSphereBrushClick intentionally skipped this so the
75823
+ // user could wheel-adjust radius first.
75824
+ if (!this.dragMoved && this.strokeCenters.length > 0) {
75825
+ const sc = this.strokeCenters[0];
75826
+ const dims = vol.getDimensions();
75827
+ const center = this.canvasToVoxelCenter(sc.x, sc.y, sc.sliceIndex, axis);
75828
+ const radii = this.getVoxelRadii(radius, axis);
75829
+ const bb = this.getBoundingBox(center, radii, dims);
75830
+ this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
75831
+ this.write3DSphereToBrush(vol, center, radii, bb, channel);
75832
+ }
75833
+ const after = this.captureSliceSnapshots(vol, this.dragMinZ(), this.dragMaxZ());
75834
+ const deltas = this.buildUndoGroup(layer, this.dragBeforeSnapshots, after);
75233
75835
  if (deltas.length > 0) {
75234
75836
  this.callbacks.pushUndoGroup(deltas);
75235
75837
  }
75236
- // Refresh display and notify backend of ALL changed slices
75237
- this.refreshDisplay(layer, deltas);
75238
- this.clearPreview();
75838
+ this.refreshDisplay(layer, deltas, true);
75839
+ this.finalizeStrokeState();
75239
75840
  }
75240
75841
  // ── Sphere Eraser Click/PointerUp ──────────────────────────────
75241
75842
  /**
@@ -75243,13 +75844,17 @@ class SphereBrushTool extends BaseTool {
75243
75844
  * Initializes drag tracking for cumulative undo.
75244
75845
  */
75245
75846
  onSphereEraserClick(e) {
75246
- this.centerX = e.offsetX / this.ctx.nrrd_states.view.sizeFactor;
75247
- this.centerY = e.offsetY / this.ctx.nrrd_states.view.sizeFactor;
75847
+ this.centerX = e.offsetX;
75848
+ this.centerY = e.offsetY;
75248
75849
  this.centerSlice = this.ctx.nrrd_states.view.currentSliceIndex;
75850
+ this.lastDisplayX = this.centerX;
75851
+ this.lastDisplayY = this.centerY;
75852
+ this.lastSliceIndex = this.centerSlice;
75249
75853
  this.active = true;
75250
75854
  this.mode = "eraser";
75251
75855
  this.dragMoved = false;
75252
75856
  this.dragBeforeSnapshots.clear();
75857
+ this.strokeCenters = [{ x: this.centerX, y: this.centerY, sliceIndex: this.centerSlice }];
75253
75858
  // Capture initial "before" snapshots for the first sphere position
75254
75859
  const layer = this.ctx.gui_states.layerChannel.layer;
75255
75860
  const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
@@ -75258,7 +75863,7 @@ class SphereBrushTool extends BaseTool {
75258
75863
  const dims = vol.getDimensions();
75259
75864
  const axis = this.ctx.protectedData.axis;
75260
75865
  const center = this.canvasToVoxelCenter(this.centerX, this.centerY, this.centerSlice, axis);
75261
- const radii = this.getVoxelRadii(radius);
75866
+ const radii = this.getVoxelRadii(radius, axis);
75262
75867
  const bb = this.getBoundingBox(center, radii, dims);
75263
75868
  this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
75264
75869
  }
@@ -75274,9 +75879,11 @@ class SphereBrushTool extends BaseTool {
75274
75879
  if (!this.active || this.mode !== "eraser")
75275
75880
  return;
75276
75881
  this.dragMoved = true;
75277
- const mouseX = e.offsetX / this.ctx.nrrd_states.view.sizeFactor;
75278
- const mouseY = e.offsetY / this.ctx.nrrd_states.view.sizeFactor;
75279
75882
  const sliceIndex = this.ctx.nrrd_states.view.currentSliceIndex;
75883
+ this.lastDisplayX = e.offsetX;
75884
+ this.lastDisplayY = e.offsetY;
75885
+ this.lastSliceIndex = sliceIndex;
75886
+ this.strokeCenters.push({ x: e.offsetX, y: e.offsetY, sliceIndex });
75280
75887
  const layer = this.ctx.gui_states.layerChannel.layer;
75281
75888
  const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
75282
75889
  const axis = this.ctx.protectedData.axis;
@@ -75289,17 +75896,18 @@ class SphereBrushTool extends BaseTool {
75289
75896
  return;
75290
75897
  }
75291
75898
  const dims = vol.getDimensions();
75292
- const center = this.canvasToVoxelCenter(mouseX, mouseY, sliceIndex, axis);
75293
- const radii = this.getVoxelRadii(radius);
75899
+ const center = this.canvasToVoxelCenter(e.offsetX, e.offsetY, sliceIndex, axis);
75900
+ const radii = this.getVoxelRadii(radius, axis);
75294
75901
  const bb = this.getBoundingBox(center, radii, dims);
75295
75902
  // Expand "before" snapshots to cover any new Z slices
75296
75903
  this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
75297
75904
  // Erase sphere at current position
75298
75905
  this.erase3DSphereFromVolume(vol, center, radii, bb, channel);
75299
- // Update preview position
75300
- this.drawPreview(mouseX, mouseY, radius, true);
75301
- // Refresh display so user sees the erase in real-time
75302
- this.refreshDisplay(layer);
75906
+ // Update preview position (pass display/zoomed coords)
75907
+ this.drawPreview(e.offsetX, e.offsetY, radius, true);
75908
+ // Refresh display locally skip backend notify to avoid per-frame HTTP.
75909
+ // onSphereEraserPointerUp batches all deltas into a single notify.
75910
+ this.refreshDisplay(layer, undefined, false);
75303
75911
  }
75304
75912
  /**
75305
75913
  * Handle pointer-up in sphereEraser mode — finalize erase + push undo group.
@@ -75318,16 +75926,17 @@ class SphereBrushTool extends BaseTool {
75318
75926
  vol = this.callbacks.getVolumeForLayer(layer);
75319
75927
  }
75320
75928
  catch (_a) {
75321
- this.clearPreview();
75322
- this.dragBeforeSnapshots.clear();
75929
+ this.finalizeStrokeState();
75323
75930
  return;
75324
75931
  }
75325
- // If no drag occurred, erase at the click position (original click-release behavior)
75326
- if (!this.dragMoved) {
75932
+ // Click-without-drag: erase at the click position (click-release behavior).
75933
+ if (!this.dragMoved && this.strokeCenters.length > 0) {
75934
+ const sc = this.strokeCenters[0];
75327
75935
  const dims = vol.getDimensions();
75328
- const center = this.canvasToVoxelCenter(this.centerX, this.centerY, this.centerSlice, axis);
75329
- const radii = this.getVoxelRadii(radius);
75936
+ const center = this.canvasToVoxelCenter(sc.x, sc.y, sc.sliceIndex, axis);
75937
+ const radii = this.getVoxelRadii(radius, axis);
75330
75938
  const bb = this.getBoundingBox(center, radii, dims);
75939
+ this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
75331
75940
  this.erase3DSphereFromVolume(vol, center, radii, bb, channel);
75332
75941
  }
75333
75942
  // Build undo group from cumulative "before" snapshots vs current state
@@ -75336,10 +75945,20 @@ class SphereBrushTool extends BaseTool {
75336
75945
  if (deltas.length > 0) {
75337
75946
  this.callbacks.pushUndoGroup(deltas);
75338
75947
  }
75339
- // Refresh display and notify backend of ALL changed slices
75340
- this.refreshDisplay(layer, deltas);
75948
+ // Refresh display and notify backend of ALL changed slices — single HTTP batch
75949
+ this.refreshDisplay(layer, deltas, true);
75950
+ this.finalizeStrokeState();
75951
+ }
75952
+ /**
75953
+ * Clear per-stroke state after pointerup or cancel. Kept as a helper so
75954
+ * brush/eraser pointerup and cancelActivePlacement all reset the same set
75955
+ * of fields — if a new drag-state field is added, only one place to update.
75956
+ */
75957
+ finalizeStrokeState() {
75341
75958
  this.clearPreview();
75342
75959
  this.dragBeforeSnapshots.clear();
75960
+ this.strokeCenters = [];
75961
+ this.dragMoved = false;
75343
75962
  }
75344
75963
  /**
75345
75964
  * Expand cumulative "before" snapshots to cover z range [minZ, maxZ].
@@ -75384,8 +76003,11 @@ class SphereBrushTool extends BaseTool {
75384
76003
  * @param changedDeltas - If provided, fire onMaskChanged for ALL changed
75385
76004
  * slices (not just the current view slice) so the backend receives the
75386
76005
  * full 3D sphere data for NII/GLTF export.
76006
+ * @param notifyBackend - When false, only refresh the local canvas and skip
76007
+ * the onMaskChanged callback. Used during drag to avoid per-frame HTTP;
76008
+ * pointerup then calls with `true` to send a single batched update.
75387
76009
  */
75388
- refreshDisplay(layerId, changedDeltas) {
76010
+ refreshDisplay(layerId, changedDeltas, notifyBackend = true) {
75389
76011
  // Re-render layer canvas from volume for the current slice
75390
76012
  const target = this.ctx.protectedData.layerTargets.get(layerId);
75391
76013
  if (target) {
@@ -75398,6 +76020,9 @@ class SphereBrushTool extends BaseTool {
75398
76020
  }
75399
76021
  // Composite all layers to master canvas
75400
76022
  this.callbacks.compositeAllLayers();
76023
+ // Skip backend notify during drag — pointerup batches a single notify for the whole stroke.
76024
+ if (!notifyBackend)
76025
+ return;
75401
76026
  // Fire onMaskChanged for ALL changed slices (not just the current view slice)
75402
76027
  // This ensures the backend receives the full 3D sphere data for export.
75403
76028
  try {
@@ -75426,6 +76051,23 @@ class SphereBrushTool extends BaseTool {
75426
76051
  get isActive() {
75427
76052
  return this.active;
75428
76053
  }
76054
+ /**
76055
+ * Defensively cancel any in-progress sphere placement and clear the preview.
76056
+ *
76057
+ * Called when crosshair mode is entered so any lingering sphere preview
76058
+ * (e.g. red dashed eraser outline) is wiped immediately. In normal flow
76059
+ * `leftButtonDown === true` blocks crosshair toggle, so this shouldn't
76060
+ * fire mid-stroke — but if it ever does, we drop the drag snapshot
76061
+ * rather than committing a partial undo entry.
76062
+ */
76063
+ cancelActivePlacement() {
76064
+ if (!this.active) {
76065
+ this.clearPreview();
76066
+ return;
76067
+ }
76068
+ this.active = false;
76069
+ this.finalizeStrokeState();
76070
+ }
75429
76071
  }
75430
76072
 
75431
76073
  /**
@@ -75530,6 +76172,8 @@ class DrawToolCore {
75530
76172
  getVolumeForLayer: (layer) => this.renderer.getVolumeForLayer(layer),
75531
76173
  pushUndoDelta: (delta) => this.undoManager.push(delta),
75532
76174
  getEraserUrls: () => this.eraserUrls,
76175
+ renderSliceToCanvas: (layer, axis, sliceIndex, buffer, ctx, w, h) => this.renderer.renderSliceToCanvas(layer, axis, sliceIndex, buffer, ctx, w, h),
76176
+ getOrCreateSliceBuffer: (axis) => this.renderer.getOrCreateSliceBuffer(axis),
75533
76177
  });
75534
76178
  this.sphereBrushTool = new SphereBrushTool(toolCtx, {
75535
76179
  getVolumeForLayer: (layer) => this.renderer.getVolumeForLayer(layer),
@@ -75548,6 +76192,7 @@ class DrawToolCore {
75548
76192
  container: this.container,
75549
76193
  canvas: this.state.protectedData.canvases.drawingCanvas,
75550
76194
  onModeChange: (prevMode, newMode) => {
76195
+ var _a;
75551
76196
  // Use string comparison to avoid TypeScript narrowing issues
75552
76197
  const prev = prevMode;
75553
76198
  const next = newMode;
@@ -75560,6 +76205,10 @@ class DrawToolCore {
75560
76205
  }
75561
76206
  if (next === 'crosshair') {
75562
76207
  this.state.protectedData.isDrawing = false;
76208
+ // Wipe any lingering sphere preview (red dashed eraser outline /
76209
+ // filled brush preview) when the user toggles into crosshair from
76210
+ // sphereBrush/sphereEraser. Safe no-op when no placement is active.
76211
+ (_a = this.sphereBrushTool) === null || _a === void 0 ? void 0 : _a.cancelActivePlacement();
75563
76212
  }
75564
76213
  }
75565
76214
  });
@@ -75658,9 +76307,14 @@ class DrawToolCore {
75658
76307
  if (this.drawingTool.isActive || this.panTool.isActive) {
75659
76308
  this.drawingPrameters.handleOnDrawingMouseMove(e);
75660
76309
  }
75661
- // Drag-erase: route move to sphereBrushTool when sphereEraser is active
75662
- if (this.sphereBrushTool.isActive && this.state.gui_states.mode.sphereEraser) {
75663
- this.sphereBrushTool.onSphereEraserMove(e);
76310
+ // Drag: route move to sphereBrushTool for both brush and eraser
76311
+ if (this.sphereBrushTool.isActive) {
76312
+ if (this.state.gui_states.mode.sphereEraser) {
76313
+ this.sphereBrushTool.onSphereEraserMove(e);
76314
+ }
76315
+ else if (this.state.gui_states.mode.sphereBrush) {
76316
+ this.sphereBrushTool.onSphereBrushMove(e);
76317
+ }
75664
76318
  }
75665
76319
  });
75666
76320
  this.eventRouter.setPointerUpHandler((e) => {
@@ -75959,6 +76613,11 @@ class DrawToolCore {
75959
76613
  const ctx = this.state.protectedData.ctxes.emptyCtx;
75960
76614
  const w = this.state.protectedData.canvases.emptyCanvas.width;
75961
76615
  const h = this.state.protectedData.canvases.emptyCanvas.height;
76616
+ // Bake step: downscale layer canvas (display resolution) to emptyCanvas
76617
+ // (voxel resolution) before label extraction. Use nearest-neighbor
76618
+ // (smoothing=false) so voxel occupancy is determined directly by the
76619
+ // drawn pixels on the layer canvas, without blending that could shift
76620
+ // the alpha>=128 threshold decision in setSliceLabelsFromImageData.
75962
76621
  ctx.imageSmoothingEnabled = false;
75963
76622
  ctx.drawImage(canvas, 0, 0, w, h);
75964
76623
  }
@@ -77665,11 +78324,15 @@ class NrrdTools {
77665
78324
  this.state.gui_states.mode.sphereBrush = true;
77666
78325
  this.dragOperator.removeDragMode();
77667
78326
  (_a = this.drawCore.eventRouter) === null || _a === void 0 ? void 0 : _a.setGuiTool('sphereBrush');
78327
+ this.state.protectedData.canvases.drawingCanvas.style.cursor =
78328
+ this.state.gui_states.viewConfig.defaultPaintCursor;
77668
78329
  break;
77669
78330
  case "sphereEraser":
77670
78331
  this.state.gui_states.mode.sphereEraser = true;
77671
78332
  this.dragOperator.removeDragMode();
77672
78333
  (_b = this.drawCore.eventRouter) === null || _b === void 0 ? void 0 : _b.setGuiTool('sphereEraser');
78334
+ this.state.protectedData.canvases.drawingCanvas.style.cursor =
78335
+ this.state.gui_states.viewConfig.defaultPaintCursor;
77673
78336
  break;
77674
78337
  }
77675
78338
  // Restore drag mode when leaving sphereBrush/sphereEraser
@@ -78642,7 +79305,7 @@ function evaluateElement(element, weights) {
78642
79305
  }
78643
79306
 
78644
79307
  // import * as kiwrious from "copper3d_plugin_heart_k";
78645
- const REVISION = "v3.3.3-beta";
79308
+ const REVISION = "v3.4.1-beta";
78646
79309
  console.log(`%cCopper3D Visualisation %cBeta:${REVISION}`, "padding: 3px;color:white; background:#023047", "padding: 3px;color:white; background:#f50a25");
78647
79310
 
78648
79311
  export { CHANNEL_COLORS, CHANNEL_HEX_COLORS, CameraViewPoint, Copper3dTrackballControls, GaussianSmoother, MeshNodeTool, NrrdTools, REVISION, addBoxHelper, addLabelToScene, configKiwriousHeart, convert3DPostoScreenPos, convertScreenPosto3DPos, copperMScene, copperMSceneRenderer, copperRenderer, copperRendererOnDemond, copperScene, copperSceneOnDemond, createTexture2D_NRRD, fullScreenListenner, kiwrious, loading, removeGuiFolderChilden, rgbaToCss, rgbaToHex, setHDRFilePath, throttle };