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.
- package/dist/Utils/segmentation/DrawToolCore.js +20 -3
- package/dist/Utils/segmentation/DrawToolCore.js.map +1 -1
- package/dist/Utils/segmentation/NrrdTools.js +4 -0
- package/dist/Utils/segmentation/NrrdTools.js.map +1 -1
- package/dist/Utils/segmentation/RenderingUtils.d.ts +9 -3
- package/dist/Utils/segmentation/RenderingUtils.js +35 -15
- package/dist/Utils/segmentation/RenderingUtils.js.map +1 -1
- package/dist/Utils/segmentation/core/MarchingSquares.d.ts +72 -0
- package/dist/Utils/segmentation/core/MarchingSquares.js +169 -0
- package/dist/Utils/segmentation/core/MarchingSquares.js.map +1 -0
- package/dist/Utils/segmentation/core/index.d.ts +2 -0
- package/dist/Utils/segmentation/core/index.js +1 -0
- package/dist/Utils/segmentation/core/index.js.map +1 -1
- package/dist/Utils/segmentation/eventRouter/EventRouter.d.ts +9 -0
- package/dist/Utils/segmentation/eventRouter/EventRouter.js +36 -8
- package/dist/Utils/segmentation/eventRouter/EventRouter.js.map +1 -1
- package/dist/Utils/segmentation/tools/DrawingTool.d.ts +35 -10
- package/dist/Utils/segmentation/tools/DrawingTool.js +247 -36
- package/dist/Utils/segmentation/tools/DrawingTool.js.map +1 -1
- package/dist/Utils/segmentation/tools/SphereBrushTool.d.ts +83 -6
- package/dist/Utils/segmentation/tools/SphereBrushTool.js +283 -68
- package/dist/Utils/segmentation/tools/SphereBrushTool.js.map +1 -1
- package/dist/Utils/segmentation/tools/ToolHost.d.ts +1 -1
- package/dist/bundle.esm.js +794 -131
- package/dist/bundle.umd.js +794 -131
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/types/Utils/segmentation/RenderingUtils.d.ts +9 -3
- package/dist/types/Utils/segmentation/core/MarchingSquares.d.ts +72 -0
- package/dist/types/Utils/segmentation/core/index.d.ts +2 -0
- package/dist/types/Utils/segmentation/eventRouter/EventRouter.d.ts +9 -0
- package/dist/types/Utils/segmentation/tools/DrawingTool.d.ts +35 -10
- package/dist/types/Utils/segmentation/tools/SphereBrushTool.d.ts +83 -6
- package/dist/types/Utils/segmentation/tools/ToolHost.d.ts +1 -1
- package/dist/types/index.d.ts +1 -1
- package/package.json +1 -1
package/dist/bundle.umd.js
CHANGED
|
@@ -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
|
|
73731
|
+
* Render a layer's slice onto the target canvas as vector contours.
|
|
73732
|
+
*
|
|
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.
|
|
73563
73737
|
*
|
|
73564
|
-
*
|
|
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,
|
|
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
|
-
|
|
73574
|
-
|
|
73575
|
-
|
|
73576
|
-
|
|
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:
|
|
73579
|
-
//
|
|
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
|
-
|
|
73586
|
-
|
|
73587
|
-
|
|
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 (
|
|
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
|
|
73806
|
-
if (tool
|
|
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
|
|
73819
|
-
if (!DRAWING_TOOLS.has(this.guiTool) && this.guiTool
|
|
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;
|
|
@@ -73851,6 +74046,19 @@ void main() {
|
|
|
73851
74046
|
isRightButtonDown() {
|
|
73852
74047
|
return this.state.rightButtonDown;
|
|
73853
74048
|
}
|
|
74049
|
+
/**
|
|
74050
|
+
* Whether the user is currently performing a slice-drag (left-button held
|
|
74051
|
+
* in idle mode with a non-sphere tool). In this state the mouse wheel is
|
|
74052
|
+
* suppressed so that slice switching via drag and slice/zoom via wheel
|
|
74053
|
+
* cannot fight each other. Draw / contrast / crosshair set `mode` away
|
|
74054
|
+
* from 'idle', and sphere-family tools use left-drag for their own
|
|
74055
|
+
* purposes, so both are correctly excluded.
|
|
74056
|
+
*/
|
|
74057
|
+
isDragSliceActive() {
|
|
74058
|
+
return (this.state.leftButtonDown &&
|
|
74059
|
+
this.mode === 'idle' &&
|
|
74060
|
+
!SPHERE_TOOLS.has(this.guiTool));
|
|
74061
|
+
}
|
|
73854
74062
|
// ========================================
|
|
73855
74063
|
// Configuration
|
|
73856
74064
|
// ========================================
|
|
@@ -73945,9 +74153,9 @@ void main() {
|
|
|
73945
74153
|
}
|
|
73946
74154
|
}
|
|
73947
74155
|
if (this.contrastEnabled && this.keyboardSettings.contrast.includes(ev.key)) {
|
|
73948
|
-
// Block contrast state when crosshair, draw, or sphere is active (mutual exclusion)
|
|
74156
|
+
// Block contrast state when crosshair, draw, or any sphere-family tool is active (mutual exclusion)
|
|
73949
74157
|
if (!this.state.crosshairEnabled && this.mode !== 'draw'
|
|
73950
|
-
&& this.guiTool
|
|
74158
|
+
&& !SPHERE_TOOLS.has(this.guiTool)) {
|
|
73951
74159
|
this.state.ctrlHeld = true;
|
|
73952
74160
|
}
|
|
73953
74161
|
}
|
|
@@ -74018,7 +74226,15 @@ void main() {
|
|
|
74018
74226
|
}
|
|
74019
74227
|
}
|
|
74020
74228
|
handleWheel(ev) {
|
|
74021
|
-
//
|
|
74229
|
+
// Mutual exclusion: slice-drag wins over wheel. While the left button
|
|
74230
|
+
// is held in a slice-drag context, swallow wheel events so the image
|
|
74231
|
+
// cannot scroll/zoom at the same time the user is dragging slices.
|
|
74232
|
+
// Re-checked on every event, so pressing the left button mid-scroll
|
|
74233
|
+
// flips us into drag mode on the very next wheel tick.
|
|
74234
|
+
if (this.isDragSliceActive()) {
|
|
74235
|
+
ev.preventDefault();
|
|
74236
|
+
return;
|
|
74237
|
+
}
|
|
74022
74238
|
if (this.wheelHandler) {
|
|
74023
74239
|
this.wheelHandler(ev);
|
|
74024
74240
|
}
|
|
@@ -74571,6 +74787,10 @@ void main() {
|
|
|
74571
74787
|
* Extracted from DrawToolCore.paintOnCanvas() closure (Phase 3).
|
|
74572
74788
|
* Handles left-click drawing operations: pencil stroke + fill, brush strokes,
|
|
74573
74789
|
* eraser, and undo snapshot capture/push.
|
|
74790
|
+
*
|
|
74791
|
+
* Phase B: Brush mode now writes voxels directly during mousemove and
|
|
74792
|
+
* re-renders via marching squares, eliminating the visual "snap" between
|
|
74793
|
+
* the smooth canvas stroke and the voxel-based post-release render.
|
|
74574
74794
|
*/
|
|
74575
74795
|
class DrawingTool extends BaseTool {
|
|
74576
74796
|
constructor(ctx, callbacks) {
|
|
@@ -74587,6 +74807,8 @@ void main() {
|
|
|
74587
74807
|
this.preDrawSlice = null;
|
|
74588
74808
|
this.preDrawAxis = "z";
|
|
74589
74809
|
this.preDrawSliceIndex = 0;
|
|
74810
|
+
/** Previous voxel-space position for Bresenham interpolation (brush mode) */
|
|
74811
|
+
this.prevVoxel = null;
|
|
74590
74812
|
this.callbacks = callbacks;
|
|
74591
74813
|
}
|
|
74592
74814
|
/** Whether a draw operation is currently active */
|
|
@@ -74606,6 +74828,7 @@ void main() {
|
|
|
74606
74828
|
this.isPainting = false;
|
|
74607
74829
|
this.drawingLines = [];
|
|
74608
74830
|
this.clearArcFn = clearArcFn;
|
|
74831
|
+
this.prevVoxel = null;
|
|
74609
74832
|
}
|
|
74610
74833
|
/**
|
|
74611
74834
|
* Called on left-click pointerdown in draw mode.
|
|
@@ -74633,6 +74856,22 @@ void main() {
|
|
|
74633
74856
|
this.ctx.nrrd_states.interaction.drawStartPos.y = e.offsetY;
|
|
74634
74857
|
// Capture pre-draw slice snapshot for undo
|
|
74635
74858
|
this.capturePreDrawSnapshot();
|
|
74859
|
+
// Brush mode: initialize voxel tracking and write first dot
|
|
74860
|
+
if (!this.ctx.gui_states.mode.pencil && !this.ctx.gui_states.mode.eraser) {
|
|
74861
|
+
const voxel = this.canvasToVoxel3D(e.offsetX, e.offsetY);
|
|
74862
|
+
this.prevVoxel = voxel;
|
|
74863
|
+
try {
|
|
74864
|
+
const vol = this.callbacks.getVolumeForLayer(this.ctx.gui_states.layerChannel.layer);
|
|
74865
|
+
const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
|
|
74866
|
+
const axis = this.ctx.protectedData.axis;
|
|
74867
|
+
const { rH, rV } = this.getVoxelBrushRadius();
|
|
74868
|
+
this.paintVoxelEllipse(vol, voxel, rH, rV, channel, axis);
|
|
74869
|
+
this.refreshLayerFromVolume();
|
|
74870
|
+
}
|
|
74871
|
+
catch (_a) {
|
|
74872
|
+
// Volume not ready
|
|
74873
|
+
}
|
|
74874
|
+
}
|
|
74636
74875
|
}
|
|
74637
74876
|
/**
|
|
74638
74877
|
* Called on pointermove during an active drawing operation.
|
|
@@ -74646,10 +74885,16 @@ void main() {
|
|
|
74646
74885
|
this.ctx.nrrd_states.flags.stepClear = 1;
|
|
74647
74886
|
(_a = this.clearArcFn) === null || _a === void 0 ? void 0 : _a.call(this, e.offsetX, e.offsetY, this.ctx.gui_states.drawing.brushAndEraserSize);
|
|
74648
74887
|
}
|
|
74649
|
-
else {
|
|
74888
|
+
else if (this.ctx.gui_states.mode.pencil) {
|
|
74889
|
+
// Pencil mode: accumulate points and draw visual lines on canvas
|
|
74650
74890
|
this.drawingLines.push({ x: e.offsetX, y: e.offsetY });
|
|
74651
74891
|
this.paintOnCanvasLayer(e.offsetX, e.offsetY);
|
|
74652
74892
|
}
|
|
74893
|
+
else {
|
|
74894
|
+
// Brush mode: write voxels directly + re-render via marching squares
|
|
74895
|
+
this.drawingLines.push({ x: e.offsetX, y: e.offsetY });
|
|
74896
|
+
this.paintBrushVoxelMove(e.offsetX, e.offsetY);
|
|
74897
|
+
}
|
|
74653
74898
|
}
|
|
74654
74899
|
}
|
|
74655
74900
|
/**
|
|
@@ -74662,11 +74907,9 @@ void main() {
|
|
|
74662
74907
|
ctx.closePath();
|
|
74663
74908
|
if (!this.ctx.gui_states.mode.eraser) {
|
|
74664
74909
|
if (this.ctx.gui_states.mode.pencil) {
|
|
74665
|
-
//
|
|
74910
|
+
// Pencil mode: fill polygon then bake to voxels
|
|
74666
74911
|
canvas.width = canvas.width;
|
|
74667
|
-
// Redraw previous layer data from volume
|
|
74668
74912
|
this.redrawPreviousImageToLayerCtx(ctx);
|
|
74669
|
-
// Draw new pencil strokes on current layer canvas
|
|
74670
74913
|
ctx.beginPath();
|
|
74671
74914
|
ctx.moveTo(this.drawingLines[0].x, this.drawingLines[0].y);
|
|
74672
74915
|
for (let i = 1; i < this.drawingLines.length; i++) {
|
|
@@ -74676,12 +74919,26 @@ void main() {
|
|
|
74676
74919
|
ctx.lineWidth = 1;
|
|
74677
74920
|
ctx.fillStyle = this.ctx.gui_states.drawing.fillColor;
|
|
74678
74921
|
ctx.fill();
|
|
74679
|
-
// Composite ALL layers to master (not just current layer)
|
|
74680
74922
|
this.callbacks.compositeAllLayers();
|
|
74923
|
+
// Pencil still needs canvas→voxel bake
|
|
74924
|
+
this.callbacks.syncLayerSliceData(this.ctx.nrrd_states.view.currentSliceIndex, this.ctx.gui_states.layerChannel.layer);
|
|
74925
|
+
// Re-render from voxels to eliminate pencil snap
|
|
74926
|
+
this.refreshLayerFromVolume();
|
|
74681
74927
|
}
|
|
74928
|
+
else {
|
|
74929
|
+
// Brush mode: voxels already written during mousemove
|
|
74930
|
+
// Just re-render final state and composite
|
|
74931
|
+
this.refreshLayerFromVolume();
|
|
74932
|
+
}
|
|
74933
|
+
}
|
|
74934
|
+
else {
|
|
74935
|
+
// Eraser mode: still needs canvas→voxel bake
|
|
74936
|
+
this.callbacks.syncLayerSliceData(this.ctx.nrrd_states.view.currentSliceIndex, this.ctx.gui_states.layerChannel.layer);
|
|
74937
|
+
// Re-render from voxels for eraser too
|
|
74938
|
+
this.refreshLayerFromVolume();
|
|
74682
74939
|
}
|
|
74683
|
-
this.callbacks.syncLayerSliceData(this.ctx.nrrd_states.view.currentSliceIndex, this.ctx.gui_states.layerChannel.layer);
|
|
74684
74940
|
this.isPainting = false;
|
|
74941
|
+
this.prevVoxel = null;
|
|
74685
74942
|
// Push undo delta
|
|
74686
74943
|
this.pushUndoDelta();
|
|
74687
74944
|
}
|
|
@@ -74692,6 +74949,7 @@ void main() {
|
|
|
74692
74949
|
*/
|
|
74693
74950
|
onPointerLeave() {
|
|
74694
74951
|
this.isPainting = false;
|
|
74952
|
+
this.prevVoxel = null;
|
|
74695
74953
|
if (!this.leftClicked)
|
|
74696
74954
|
return false;
|
|
74697
74955
|
this.leftClicked = false;
|
|
@@ -74702,11 +74960,6 @@ void main() {
|
|
|
74702
74960
|
/**
|
|
74703
74961
|
* Create a self-managing mouseover/mouseout/mousemove handler
|
|
74704
74962
|
* 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
74963
|
*/
|
|
74711
74964
|
createBrushTrackingHandler() {
|
|
74712
74965
|
const handler = (e) => {
|
|
@@ -74730,8 +74983,6 @@ void main() {
|
|
|
74730
74983
|
}
|
|
74731
74984
|
/**
|
|
74732
74985
|
* 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
74986
|
*/
|
|
74736
74987
|
renderBrushPreview(ctx, width, height) {
|
|
74737
74988
|
if (this.ctx.gui_states.mode.pencil ||
|
|
@@ -74746,6 +74997,195 @@ void main() {
|
|
|
74746
74997
|
ctx.strokeStyle = this.ctx.gui_states.drawing.brushColor;
|
|
74747
74998
|
ctx.stroke();
|
|
74748
74999
|
}
|
|
75000
|
+
// ── Brush mode: direct voxel write ────────────────────────────
|
|
75001
|
+
/**
|
|
75002
|
+
* Convert display (zoomed) coordinates to 3D voxel coordinates.
|
|
75003
|
+
* Same logic as SphereBrushTool.canvasToVoxelCenter.
|
|
75004
|
+
*/
|
|
75005
|
+
canvasToVoxel3D(displayX, displayY) {
|
|
75006
|
+
const nrrd = this.ctx.nrrd_states;
|
|
75007
|
+
const axis = this.ctx.protectedData.axis;
|
|
75008
|
+
const cw = nrrd.view.changedWidth;
|
|
75009
|
+
const ch = nrrd.view.changedHeight;
|
|
75010
|
+
const sliceIndex = nrrd.view.currentSliceIndex;
|
|
75011
|
+
switch (axis) {
|
|
75012
|
+
case 'z':
|
|
75013
|
+
return {
|
|
75014
|
+
x: displayX * nrrd.image.nrrd_x_pixel / cw,
|
|
75015
|
+
y: displayY * nrrd.image.nrrd_y_pixel / ch,
|
|
75016
|
+
z: sliceIndex,
|
|
75017
|
+
};
|
|
75018
|
+
case 'y':
|
|
75019
|
+
return {
|
|
75020
|
+
x: displayX * nrrd.image.nrrd_x_pixel / cw,
|
|
75021
|
+
y: sliceIndex,
|
|
75022
|
+
z: (ch - displayY) * nrrd.image.nrrd_z_pixel / ch,
|
|
75023
|
+
};
|
|
75024
|
+
case 'x':
|
|
75025
|
+
return {
|
|
75026
|
+
x: sliceIndex,
|
|
75027
|
+
y: displayY * nrrd.image.nrrd_y_pixel / ch,
|
|
75028
|
+
z: displayX * nrrd.image.nrrd_z_pixel / cw,
|
|
75029
|
+
};
|
|
75030
|
+
}
|
|
75031
|
+
}
|
|
75032
|
+
/**
|
|
75033
|
+
* Get voxel-space brush radius for visible axes.
|
|
75034
|
+
* Converts display-pixel brush size to voxel units.
|
|
75035
|
+
*/
|
|
75036
|
+
getVoxelBrushRadius() {
|
|
75037
|
+
const nrrd = this.ctx.nrrd_states;
|
|
75038
|
+
const axis = this.ctx.protectedData.axis;
|
|
75039
|
+
const cw = nrrd.view.changedWidth;
|
|
75040
|
+
const ch = nrrd.view.changedHeight;
|
|
75041
|
+
const displayRadius = this.ctx.gui_states.drawing.brushAndEraserSize / 2;
|
|
75042
|
+
switch (axis) {
|
|
75043
|
+
case 'z':
|
|
75044
|
+
return {
|
|
75045
|
+
rH: displayRadius * nrrd.image.nrrd_x_pixel / cw,
|
|
75046
|
+
rV: displayRadius * nrrd.image.nrrd_y_pixel / ch,
|
|
75047
|
+
};
|
|
75048
|
+
case 'y':
|
|
75049
|
+
return {
|
|
75050
|
+
rH: displayRadius * nrrd.image.nrrd_x_pixel / cw,
|
|
75051
|
+
rV: displayRadius * nrrd.image.nrrd_z_pixel / ch,
|
|
75052
|
+
};
|
|
75053
|
+
case 'x':
|
|
75054
|
+
return {
|
|
75055
|
+
rH: displayRadius * nrrd.image.nrrd_z_pixel / cw,
|
|
75056
|
+
rV: displayRadius * nrrd.image.nrrd_y_pixel / ch,
|
|
75057
|
+
};
|
|
75058
|
+
}
|
|
75059
|
+
}
|
|
75060
|
+
/**
|
|
75061
|
+
* Paint a 2D ellipse of voxels on the current slice.
|
|
75062
|
+
* Only the two visible axes are iterated; the slice-direction axis is fixed.
|
|
75063
|
+
*/
|
|
75064
|
+
paintVoxelEllipse(vol, center, rH, rV, label, axis) {
|
|
75065
|
+
const dims = vol.getDimensions();
|
|
75066
|
+
switch (axis) {
|
|
75067
|
+
case 'z': {
|
|
75068
|
+
const minX = Math.max(0, Math.floor(center.x - rH));
|
|
75069
|
+
const maxX = Math.min(dims.width - 1, Math.ceil(center.x + rH));
|
|
75070
|
+
const minY = Math.max(0, Math.floor(center.y - rV));
|
|
75071
|
+
const maxY = Math.min(dims.height - 1, Math.ceil(center.y + rV));
|
|
75072
|
+
const z = Math.round(center.z);
|
|
75073
|
+
for (let y = minY; y <= maxY; y++) {
|
|
75074
|
+
for (let x = minX; x <= maxX; x++) {
|
|
75075
|
+
const dx = rH > 0 ? (x - center.x) / rH : 0;
|
|
75076
|
+
const dy = rV > 0 ? (y - center.y) / rV : 0;
|
|
75077
|
+
if (dx * dx + dy * dy <= 1.0) {
|
|
75078
|
+
vol.setVoxel(x, y, z, label);
|
|
75079
|
+
}
|
|
75080
|
+
}
|
|
75081
|
+
}
|
|
75082
|
+
break;
|
|
75083
|
+
}
|
|
75084
|
+
case 'y': {
|
|
75085
|
+
const minX = Math.max(0, Math.floor(center.x - rH));
|
|
75086
|
+
const maxX = Math.min(dims.width - 1, Math.ceil(center.x + rH));
|
|
75087
|
+
const minZ = Math.max(0, Math.floor(center.z - rV));
|
|
75088
|
+
const maxZ = Math.min(dims.depth - 1, Math.ceil(center.z + rV));
|
|
75089
|
+
const y = Math.round(center.y);
|
|
75090
|
+
for (let z = minZ; z <= maxZ; z++) {
|
|
75091
|
+
for (let x = minX; x <= maxX; x++) {
|
|
75092
|
+
const dx = rH > 0 ? (x - center.x) / rH : 0;
|
|
75093
|
+
const dz = rV > 0 ? (z - center.z) / rV : 0;
|
|
75094
|
+
if (dx * dx + dz * dz <= 1.0) {
|
|
75095
|
+
vol.setVoxel(x, y, z, label);
|
|
75096
|
+
}
|
|
75097
|
+
}
|
|
75098
|
+
}
|
|
75099
|
+
break;
|
|
75100
|
+
}
|
|
75101
|
+
case 'x': {
|
|
75102
|
+
const minZ = Math.max(0, Math.floor(center.z - rH));
|
|
75103
|
+
const maxZ = Math.min(dims.depth - 1, Math.ceil(center.z + rH));
|
|
75104
|
+
const minY = Math.max(0, Math.floor(center.y - rV));
|
|
75105
|
+
const maxY = Math.min(dims.height - 1, Math.ceil(center.y + rV));
|
|
75106
|
+
const x = Math.round(center.x);
|
|
75107
|
+
for (let yy = minY; yy <= maxY; yy++) {
|
|
75108
|
+
for (let zz = minZ; zz <= maxZ; zz++) {
|
|
75109
|
+
const dz = rH > 0 ? (zz - center.z) / rH : 0;
|
|
75110
|
+
const dy = rV > 0 ? (yy - center.y) / rV : 0;
|
|
75111
|
+
if (dz * dz + dy * dy <= 1.0) {
|
|
75112
|
+
vol.setVoxel(x, yy, zz, label);
|
|
75113
|
+
}
|
|
75114
|
+
}
|
|
75115
|
+
}
|
|
75116
|
+
break;
|
|
75117
|
+
}
|
|
75118
|
+
}
|
|
75119
|
+
}
|
|
75120
|
+
/**
|
|
75121
|
+
* Brush mode mousemove: write voxels along stroke segment, then re-render.
|
|
75122
|
+
* Uses linear interpolation between prev and current positions to ensure
|
|
75123
|
+
* no gaps when the mouse moves fast.
|
|
75124
|
+
*/
|
|
75125
|
+
paintBrushVoxelMove(displayX, displayY) {
|
|
75126
|
+
try {
|
|
75127
|
+
const vol = this.callbacks.getVolumeForLayer(this.ctx.gui_states.layerChannel.layer);
|
|
75128
|
+
const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
|
|
75129
|
+
const axis = this.ctx.protectedData.axis;
|
|
75130
|
+
const { rH, rV } = this.getVoxelBrushRadius();
|
|
75131
|
+
const current = this.canvasToVoxel3D(displayX, displayY);
|
|
75132
|
+
if (this.prevVoxel) {
|
|
75133
|
+
// Interpolate from prev to current to fill gaps
|
|
75134
|
+
const prev = this.prevVoxel;
|
|
75135
|
+
let steps;
|
|
75136
|
+
switch (axis) {
|
|
75137
|
+
case 'z':
|
|
75138
|
+
steps = Math.max(Math.abs(current.x - prev.x), Math.abs(current.y - prev.y));
|
|
75139
|
+
break;
|
|
75140
|
+
case 'y':
|
|
75141
|
+
steps = Math.max(Math.abs(current.x - prev.x), Math.abs(current.z - prev.z));
|
|
75142
|
+
break;
|
|
75143
|
+
case 'x':
|
|
75144
|
+
steps = Math.max(Math.abs(current.z - prev.z), Math.abs(current.y - prev.y));
|
|
75145
|
+
break;
|
|
75146
|
+
}
|
|
75147
|
+
steps = Math.max(1, Math.ceil(steps));
|
|
75148
|
+
for (let i = 0; i <= steps; i++) {
|
|
75149
|
+
const t = steps === 0 ? 0 : i / steps;
|
|
75150
|
+
const pt = {
|
|
75151
|
+
x: prev.x + (current.x - prev.x) * t,
|
|
75152
|
+
y: prev.y + (current.y - prev.y) * t,
|
|
75153
|
+
z: prev.z + (current.z - prev.z) * t,
|
|
75154
|
+
};
|
|
75155
|
+
this.paintVoxelEllipse(vol, pt, rH, rV, channel, axis);
|
|
75156
|
+
}
|
|
75157
|
+
}
|
|
75158
|
+
else {
|
|
75159
|
+
this.paintVoxelEllipse(vol, current, rH, rV, channel, axis);
|
|
75160
|
+
}
|
|
75161
|
+
this.prevVoxel = current;
|
|
75162
|
+
// Re-render the layer canvas from volume (marching squares)
|
|
75163
|
+
this.refreshLayerFromVolume();
|
|
75164
|
+
}
|
|
75165
|
+
catch (_a) {
|
|
75166
|
+
// Volume not ready — fall back to canvas drawing
|
|
75167
|
+
this.paintOnCanvasLayer(displayX, displayY);
|
|
75168
|
+
}
|
|
75169
|
+
}
|
|
75170
|
+
/**
|
|
75171
|
+
* Re-render the active layer's canvas from MaskVolume via marching squares,
|
|
75172
|
+
* then composite all layers to master.
|
|
75173
|
+
*/
|
|
75174
|
+
refreshLayerFromVolume() {
|
|
75175
|
+
const layer = this.ctx.gui_states.layerChannel.layer;
|
|
75176
|
+
const target = this.ctx.protectedData.layerTargets.get(layer);
|
|
75177
|
+
if (!target)
|
|
75178
|
+
return;
|
|
75179
|
+
const { ctx, canvas } = target;
|
|
75180
|
+
canvas.width = canvas.width; // clear
|
|
75181
|
+
const axis = this.ctx.protectedData.axis;
|
|
75182
|
+
const buffer = this.callbacks.getOrCreateSliceBuffer(axis);
|
|
75183
|
+
if (buffer) {
|
|
75184
|
+
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);
|
|
75185
|
+
}
|
|
75186
|
+
this.callbacks.compositeAllLayers();
|
|
75187
|
+
this.ctx.protectedData.mainPreSlices.mesh.material.map.needsUpdate = true;
|
|
75188
|
+
}
|
|
74749
75189
|
// ── Private helpers ────────────────────────────────────────
|
|
74750
75190
|
/** Capture pre-draw slice snapshot for undo */
|
|
74751
75191
|
capturePreDrawSnapshot() {
|
|
@@ -74782,27 +75222,18 @@ void main() {
|
|
|
74782
75222
|
}
|
|
74783
75223
|
/**
|
|
74784
75224
|
* Redraws persisted layer data onto ctx before new pencil fill.
|
|
74785
|
-
*
|
|
75225
|
+
* Delegates to the host's vector renderSliceToCanvas for consistency.
|
|
74786
75226
|
*/
|
|
74787
75227
|
redrawPreviousImageToLayerCtx(ctx) {
|
|
74788
|
-
|
|
74789
|
-
const
|
|
74790
|
-
this.ctx.
|
|
74791
|
-
|
|
74792
|
-
|
|
74793
|
-
|
|
74794
|
-
|
|
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
|
-
}
|
|
75228
|
+
const axis = this.ctx.protectedData.axis;
|
|
75229
|
+
const W = this.ctx.nrrd_states.view.changedWidth;
|
|
75230
|
+
const H = this.ctx.nrrd_states.view.changedHeight;
|
|
75231
|
+
const buffer = this.callbacks.getOrCreateSliceBuffer(axis);
|
|
75232
|
+
if (!buffer)
|
|
75233
|
+
return;
|
|
75234
|
+
this.callbacks.renderSliceToCanvas(this.ctx.gui_states.layerChannel.layer, axis, this.ctx.nrrd_states.view.currentSliceIndex, buffer, ctx, W, H);
|
|
74804
75235
|
}
|
|
74805
|
-
/** Draw a line segment on a layer canvas */
|
|
75236
|
+
/** Draw a line segment on a layer canvas (pencil mode only) */
|
|
74806
75237
|
drawLinesOnLayer(ctx, x, y) {
|
|
74807
75238
|
ctx.beginPath();
|
|
74808
75239
|
ctx.moveTo(this.ctx.nrrd_states.interaction.drawStartPos.x, this.ctx.nrrd_states.interaction.drawStartPos.y);
|
|
@@ -74818,17 +75249,13 @@ void main() {
|
|
|
74818
75249
|
ctx.stroke();
|
|
74819
75250
|
ctx.closePath();
|
|
74820
75251
|
}
|
|
74821
|
-
/** Paint a segment on the current layer canvas and composite to master */
|
|
75252
|
+
/** Paint a segment on the current layer canvas and composite to master (pencil mode) */
|
|
74822
75253
|
paintOnCanvasLayer(x, y) {
|
|
74823
75254
|
const { ctx } = this.callbacks.setCurrentLayer();
|
|
74824
|
-
// Draw only on the current layer canvas (not master directly)
|
|
74825
75255
|
this.drawLinesOnLayer(ctx, x, y);
|
|
74826
|
-
// Composite all layers to master to preserve other layers' data
|
|
74827
75256
|
this.callbacks.compositeAllLayers();
|
|
74828
|
-
// Reset drawing start position to current position
|
|
74829
75257
|
this.ctx.nrrd_states.interaction.drawStartPos.x = x;
|
|
74830
75258
|
this.ctx.nrrd_states.interaction.drawStartPos.y = y;
|
|
74831
|
-
// Flag the map as needing updating
|
|
74832
75259
|
this.ctx.protectedData.mainPreSlices.mesh.material.map.needsUpdate = true;
|
|
74833
75260
|
}
|
|
74834
75261
|
}
|
|
@@ -74968,7 +75395,11 @@ void main() {
|
|
|
74968
75395
|
class SphereBrushTool extends BaseTool {
|
|
74969
75396
|
constructor(ctx, callbacks) {
|
|
74970
75397
|
super(ctx);
|
|
74971
|
-
/**
|
|
75398
|
+
/**
|
|
75399
|
+
* Recorded sphere center in display (zoomed) coordinates.
|
|
75400
|
+
* Used for both preview redraw and voxel calculation — the voxel conversion
|
|
75401
|
+
* uses `changedWidth/Height` as the display scale, so no mm detour is needed.
|
|
75402
|
+
*/
|
|
74972
75403
|
this.centerX = 0;
|
|
74973
75404
|
this.centerY = 0;
|
|
74974
75405
|
this.centerSlice = 0;
|
|
@@ -74976,10 +75407,24 @@ void main() {
|
|
|
74976
75407
|
this.active = false;
|
|
74977
75408
|
/** Current operation mode for the active placement */
|
|
74978
75409
|
this.mode = "brush";
|
|
74979
|
-
/** Cumulative "before" snapshots for drag
|
|
75410
|
+
/** Cumulative "before" snapshots for drag undo (z-index → slice data) — used by both brush and eraser drag */
|
|
74980
75411
|
this.dragBeforeSnapshots = new Map();
|
|
74981
|
-
/** Whether a drag
|
|
75412
|
+
/** Whether a drag has actually moved (vs. simple click-release) */
|
|
74982
75413
|
this.dragMoved = false;
|
|
75414
|
+
/**
|
|
75415
|
+
* Latest cursor position in display (zoomed) coords. Used by the wheel
|
|
75416
|
+
* handler to redraw the preview at the cursor, not the original mousedown
|
|
75417
|
+
* point, during mid-drag radius changes.
|
|
75418
|
+
*/
|
|
75419
|
+
this.lastDisplayX = 0;
|
|
75420
|
+
this.lastDisplayY = 0;
|
|
75421
|
+
this.lastSliceIndex = 0;
|
|
75422
|
+
/**
|
|
75423
|
+
* All sphere centers written during the current stroke. The wheel handler
|
|
75424
|
+
* retroactively re-renders the entire stroke at the new radius by restoring
|
|
75425
|
+
* `dragBeforeSnapshots` and replaying every entry here.
|
|
75426
|
+
*/
|
|
75427
|
+
this.strokeCenters = [];
|
|
74983
75428
|
this.callbacks = callbacks;
|
|
74984
75429
|
}
|
|
74985
75430
|
setCallbacks(callbacks) {
|
|
@@ -74987,41 +75432,66 @@ void main() {
|
|
|
74987
75432
|
}
|
|
74988
75433
|
// ── Geometry (ported from SphereTool) ──────────────────────────
|
|
74989
75434
|
/**
|
|
74990
|
-
* Convert
|
|
75435
|
+
* Convert display (zoomed) coordinates to 3D voxel coordinates.
|
|
75436
|
+
*
|
|
75437
|
+
* Uses `changedWidth/Height` as the display scale directly (instead of
|
|
75438
|
+
* going through mm via `/sizeFactor`). This keeps the write pixel-exact
|
|
75439
|
+
* with the preview circle, which is drawn on a `changedWidth x changedHeight`
|
|
75440
|
+
* sized sphere canvas — avoiding floating-point drift at high zoom levels.
|
|
74991
75441
|
*/
|
|
74992
|
-
canvasToVoxelCenter(
|
|
75442
|
+
canvasToVoxelCenter(displayX, displayY, sliceIndex, axis) {
|
|
74993
75443
|
const nrrd = this.ctx.nrrd_states;
|
|
75444
|
+
const cw = nrrd.view.changedWidth;
|
|
75445
|
+
const ch = nrrd.view.changedHeight;
|
|
74994
75446
|
switch (axis) {
|
|
74995
75447
|
case 'z':
|
|
74996
75448
|
return {
|
|
74997
|
-
x:
|
|
74998
|
-
y:
|
|
75449
|
+
x: displayX * nrrd.image.nrrd_x_pixel / cw,
|
|
75450
|
+
y: displayY * nrrd.image.nrrd_y_pixel / ch,
|
|
74999
75451
|
z: sliceIndex,
|
|
75000
75452
|
};
|
|
75001
75453
|
case 'y':
|
|
75002
75454
|
return {
|
|
75003
|
-
x:
|
|
75455
|
+
x: displayX * nrrd.image.nrrd_x_pixel / cw,
|
|
75004
75456
|
y: sliceIndex,
|
|
75005
|
-
|
|
75457
|
+
// Coronal vertical flip: display Y=0 is top, voxel Z increases downward in mm-space
|
|
75458
|
+
z: (ch - displayY) * nrrd.image.nrrd_z_pixel / ch,
|
|
75006
75459
|
};
|
|
75007
75460
|
case 'x':
|
|
75008
75461
|
return {
|
|
75009
75462
|
x: sliceIndex,
|
|
75010
|
-
y:
|
|
75011
|
-
z:
|
|
75463
|
+
y: displayY * nrrd.image.nrrd_y_pixel / ch,
|
|
75464
|
+
z: displayX * nrrd.image.nrrd_z_pixel / cw,
|
|
75012
75465
|
};
|
|
75013
75466
|
}
|
|
75014
75467
|
}
|
|
75015
75468
|
/**
|
|
75016
75469
|
* Convert mm radius to per-axis voxel radii.
|
|
75470
|
+
*
|
|
75471
|
+
* For the two axes visible in the current view, scale via
|
|
75472
|
+
* `changedWidth/Height` so the rendered ellipsoid cross-section matches
|
|
75473
|
+
* the preview pixel radius exactly. The third (slice-direction) axis is
|
|
75474
|
+
* not visible, so it uses the plain mm-based ratio.
|
|
75017
75475
|
*/
|
|
75018
|
-
getVoxelRadii(radius) {
|
|
75476
|
+
getVoxelRadii(radius, axis) {
|
|
75019
75477
|
const nrrd = this.ctx.nrrd_states;
|
|
75020
|
-
|
|
75021
|
-
|
|
75022
|
-
|
|
75023
|
-
|
|
75024
|
-
|
|
75478
|
+
const sf = nrrd.view.sizeFactor;
|
|
75479
|
+
const cw = nrrd.view.changedWidth;
|
|
75480
|
+
const ch = nrrd.view.changedHeight;
|
|
75481
|
+
// pixel-exact voxel radius for the visible horizontal/vertical dims
|
|
75482
|
+
const rxView = radius * sf * nrrd.image.nrrd_x_pixel / cw;
|
|
75483
|
+
const ryView = radius * sf * nrrd.image.nrrd_y_pixel / ch;
|
|
75484
|
+
const rzHoriz = radius * sf * nrrd.image.nrrd_z_pixel / cw;
|
|
75485
|
+
const rzVert = radius * sf * nrrd.image.nrrd_z_pixel / ch;
|
|
75486
|
+
// mm-based fallback for the slice-direction (perpendicular) dim
|
|
75487
|
+
const rxMm = radius * nrrd.image.nrrd_x_pixel / nrrd.image.nrrd_x_mm;
|
|
75488
|
+
const ryMm = radius * nrrd.image.nrrd_y_pixel / nrrd.image.nrrd_y_mm;
|
|
75489
|
+
const rzMm = radius * nrrd.image.nrrd_z_pixel / nrrd.image.nrrd_z_mm;
|
|
75490
|
+
switch (axis) {
|
|
75491
|
+
case 'z': return { rx: rxView, ry: ryView, rz: rzMm };
|
|
75492
|
+
case 'y': return { rx: rxView, ry: ryMm, rz: rzVert };
|
|
75493
|
+
case 'x': return { rx: rxMm, ry: ryView, rz: rzHoriz };
|
|
75494
|
+
}
|
|
75025
75495
|
}
|
|
75026
75496
|
/**
|
|
75027
75497
|
* Compute clamped bounding box for an ellipsoid in voxel space.
|
|
@@ -75126,11 +75596,15 @@ void main() {
|
|
|
75126
75596
|
drawPreview(mouseX, mouseY, radius, isEraser) {
|
|
75127
75597
|
const sphereCanvas = this.ctx.protectedData.canvases.drawingSphereCanvas;
|
|
75128
75598
|
const sphereCtx = this.ctx.protectedData.ctxes.drawingSphereCtx;
|
|
75129
|
-
//
|
|
75130
|
-
|
|
75131
|
-
|
|
75599
|
+
// Size sphere canvas to the zoomed display size so start() draws it 1:1 —
|
|
75600
|
+
// no scaling step, eliminating any zoom-dependent positioning error.
|
|
75601
|
+
const sf = this.ctx.nrrd_states.view.sizeFactor;
|
|
75602
|
+
sphereCanvas.width = this.ctx.nrrd_states.view.changedWidth;
|
|
75603
|
+
sphereCanvas.height = this.ctx.nrrd_states.view.changedHeight;
|
|
75604
|
+
// mouseX/mouseY are already in zoomed display coords; scale radius to match.
|
|
75605
|
+
const scaledRadius = radius * sf;
|
|
75132
75606
|
sphereCtx.beginPath();
|
|
75133
|
-
sphereCtx.arc(mouseX, mouseY,
|
|
75607
|
+
sphereCtx.arc(mouseX, mouseY, scaledRadius, 0, 2 * Math.PI);
|
|
75134
75608
|
if (isEraser) {
|
|
75135
75609
|
// Eraser preview: dashed outline
|
|
75136
75610
|
sphereCtx.strokeStyle = "#ff4444";
|
|
@@ -75183,38 +75657,135 @@ void main() {
|
|
|
75183
75657
|
return (e) => {
|
|
75184
75658
|
e.preventDefault();
|
|
75185
75659
|
const sphere = this.ctx.nrrd_states.sphere;
|
|
75186
|
-
|
|
75187
|
-
|
|
75660
|
+
sphere.sphereBrushRadius = Math.max(1, Math.min(sphere.sphereBrushRadius + (e.deltaY < 0 ? 1 : -1), 50));
|
|
75661
|
+
// 3D Slicer-style retroactive resize: if the user has already started
|
|
75662
|
+
// painting/erasing a stroke, re-render the entire stroke at the new
|
|
75663
|
+
// radius so the whole pipe/erase-path resizes together.
|
|
75664
|
+
if (this.active && this.dragMoved && this.strokeCenters.length > 0) {
|
|
75665
|
+
this.replayStrokeWithCurrentRadius();
|
|
75188
75666
|
}
|
|
75189
|
-
|
|
75190
|
-
|
|
75191
|
-
|
|
75192
|
-
sphere.sphereBrushRadius = Math.max(1, Math.min(sphere.sphereBrushRadius, 50));
|
|
75193
|
-
// Redraw preview
|
|
75667
|
+
// Preview always follows the latest cursor position, not the mousedown
|
|
75668
|
+
// point — otherwise the preview circle appears stuck on click-down when
|
|
75669
|
+
// the user scrolls after dragging elsewhere.
|
|
75194
75670
|
if (this.active) {
|
|
75195
|
-
this.drawPreview(this.
|
|
75671
|
+
this.drawPreview(this.lastDisplayX, this.lastDisplayY, sphere.sphereBrushRadius, this.mode === "eraser");
|
|
75196
75672
|
}
|
|
75197
75673
|
};
|
|
75198
75674
|
}
|
|
75675
|
+
/**
|
|
75676
|
+
* Retroactively re-render the current stroke at the current radius.
|
|
75677
|
+
*
|
|
75678
|
+
* Step 1: walk every recorded stroke center and expand
|
|
75679
|
+
* `dragBeforeSnapshots` to cover the new (larger) bounding box. Slices
|
|
75680
|
+
* newly included here are still pristine because the original stroke
|
|
75681
|
+
* never touched them.
|
|
75682
|
+
* Step 2: restore every snapshotted slice from its pristine backup.
|
|
75683
|
+
* Step 3: replay every stroke center at the new radius.
|
|
75684
|
+
*
|
|
75685
|
+
* Step 3 produces the same final state as if the user had used the new
|
|
75686
|
+
* radius from the start. Undo still captures the full stroke as one group
|
|
75687
|
+
* at pointerup.
|
|
75688
|
+
*/
|
|
75689
|
+
replayStrokeWithCurrentRadius() {
|
|
75690
|
+
if (this.strokeCenters.length === 0)
|
|
75691
|
+
return;
|
|
75692
|
+
const layer = this.ctx.gui_states.layerChannel.layer;
|
|
75693
|
+
const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
|
|
75694
|
+
const axis = this.ctx.protectedData.axis;
|
|
75695
|
+
const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
|
|
75696
|
+
let vol;
|
|
75697
|
+
try {
|
|
75698
|
+
vol = this.callbacks.getVolumeForLayer(layer);
|
|
75699
|
+
}
|
|
75700
|
+
catch (_a) {
|
|
75701
|
+
return;
|
|
75702
|
+
}
|
|
75703
|
+
const dims = vol.getDimensions();
|
|
75704
|
+
// 1. Expand snapshot coverage using the new radius so any newly-exposed
|
|
75705
|
+
// Z slices are captured while still pristine.
|
|
75706
|
+
for (const sc of this.strokeCenters) {
|
|
75707
|
+
const center = this.canvasToVoxelCenter(sc.x, sc.y, sc.sliceIndex, axis);
|
|
75708
|
+
const radii = this.getVoxelRadii(radius, axis);
|
|
75709
|
+
const bb = this.getBoundingBox(center, radii, dims);
|
|
75710
|
+
this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
|
|
75711
|
+
}
|
|
75712
|
+
// 2. Restore every snapshotted slice from pristine backup.
|
|
75713
|
+
for (const [z, pristine] of this.dragBeforeSnapshots) {
|
|
75714
|
+
try {
|
|
75715
|
+
vol.setSliceUint8(z, pristine, 'z');
|
|
75716
|
+
}
|
|
75717
|
+
catch (_b) {
|
|
75718
|
+
// Slice write failed — skip silently so a single bad slice doesn't break the replay.
|
|
75719
|
+
}
|
|
75720
|
+
}
|
|
75721
|
+
// 3. Replay every stroke center with the new radius.
|
|
75722
|
+
for (const sc of this.strokeCenters) {
|
|
75723
|
+
const center = this.canvasToVoxelCenter(sc.x, sc.y, sc.sliceIndex, axis);
|
|
75724
|
+
const radii = this.getVoxelRadii(radius, axis);
|
|
75725
|
+
const bb = this.getBoundingBox(center, radii, dims);
|
|
75726
|
+
if (this.mode === "brush") {
|
|
75727
|
+
this.write3DSphereToBrush(vol, center, radii, bb, channel);
|
|
75728
|
+
}
|
|
75729
|
+
else {
|
|
75730
|
+
this.erase3DSphereFromVolume(vol, center, radii, bb, channel);
|
|
75731
|
+
}
|
|
75732
|
+
}
|
|
75733
|
+
this.refreshDisplay(layer, undefined, false);
|
|
75734
|
+
}
|
|
75199
75735
|
// ── Sphere Brush Click/PointerUp ───────────────────────────────
|
|
75200
75736
|
/**
|
|
75201
|
-
* Handle pointer-down in sphereBrush mode (
|
|
75737
|
+
* Handle pointer-down in sphereBrush mode (3D Slicer-style).
|
|
75738
|
+
*
|
|
75739
|
+
* Captures pristine snapshots at the mousedown location but does NOT
|
|
75740
|
+
* write any voxels yet — the user may wheel-adjust the radius before
|
|
75741
|
+
* dragging. The first sphere is written either by the first pointermove
|
|
75742
|
+
* or by pointerup (single-click fallback).
|
|
75202
75743
|
*/
|
|
75203
75744
|
onSphereBrushClick(e) {
|
|
75204
|
-
this.centerX = e.offsetX
|
|
75205
|
-
this.centerY = e.offsetY
|
|
75745
|
+
this.centerX = e.offsetX;
|
|
75746
|
+
this.centerY = e.offsetY;
|
|
75206
75747
|
this.centerSlice = this.ctx.nrrd_states.view.currentSliceIndex;
|
|
75748
|
+
this.lastDisplayX = this.centerX;
|
|
75749
|
+
this.lastDisplayY = this.centerY;
|
|
75750
|
+
this.lastSliceIndex = this.centerSlice;
|
|
75207
75751
|
this.active = true;
|
|
75208
75752
|
this.mode = "brush";
|
|
75209
|
-
this.
|
|
75753
|
+
this.dragMoved = false;
|
|
75754
|
+
this.dragBeforeSnapshots.clear();
|
|
75755
|
+
this.strokeCenters = [{ x: this.centerX, y: this.centerY, sliceIndex: this.centerSlice }];
|
|
75756
|
+
const layer = this.ctx.gui_states.layerChannel.layer;
|
|
75757
|
+
const axis = this.ctx.protectedData.axis;
|
|
75758
|
+
const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
|
|
75759
|
+
try {
|
|
75760
|
+
const vol = this.callbacks.getVolumeForLayer(layer);
|
|
75761
|
+
const dims = vol.getDimensions();
|
|
75762
|
+
const center = this.canvasToVoxelCenter(this.centerX, this.centerY, this.centerSlice, axis);
|
|
75763
|
+
const radii = this.getVoxelRadii(radius, axis);
|
|
75764
|
+
const bb = this.getBoundingBox(center, radii, dims);
|
|
75765
|
+
this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
|
|
75766
|
+
}
|
|
75767
|
+
catch (_a) {
|
|
75768
|
+
// Volume not ready — preview still shown so user gets visual feedback
|
|
75769
|
+
}
|
|
75770
|
+
this.drawPreview(this.centerX, this.centerY, radius, false);
|
|
75210
75771
|
}
|
|
75211
75772
|
/**
|
|
75212
|
-
* Handle pointer-
|
|
75773
|
+
* Handle pointer-move in sphereBrush mode — drag to continuously paint.
|
|
75774
|
+
*
|
|
75775
|
+
* Mirrors sphereEraser drag: writes a new sphere at each move position,
|
|
75776
|
+
* extends the cumulative before-snapshot to cover any newly-touched Z
|
|
75777
|
+
* slices, and repaints the preview. Never notifies the backend; pointerup
|
|
75778
|
+
* does that once for the whole stroke.
|
|
75213
75779
|
*/
|
|
75214
|
-
|
|
75780
|
+
onSphereBrushMove(e) {
|
|
75215
75781
|
if (!this.active || this.mode !== "brush")
|
|
75216
75782
|
return;
|
|
75217
|
-
this.
|
|
75783
|
+
this.dragMoved = true;
|
|
75784
|
+
const sliceIndex = this.ctx.nrrd_states.view.currentSliceIndex;
|
|
75785
|
+
this.lastDisplayX = e.offsetX;
|
|
75786
|
+
this.lastDisplayY = e.offsetY;
|
|
75787
|
+
this.lastSliceIndex = sliceIndex;
|
|
75788
|
+
this.strokeCenters.push({ x: e.offsetX, y: e.offsetY, sliceIndex });
|
|
75218
75789
|
const layer = this.ctx.gui_states.layerChannel.layer;
|
|
75219
75790
|
const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
|
|
75220
75791
|
const axis = this.ctx.protectedData.axis;
|
|
@@ -75224,26 +75795,56 @@ void main() {
|
|
|
75224
75795
|
vol = this.callbacks.getVolumeForLayer(layer);
|
|
75225
75796
|
}
|
|
75226
75797
|
catch (_a) {
|
|
75227
|
-
this.clearPreview();
|
|
75228
75798
|
return;
|
|
75229
75799
|
}
|
|
75230
75800
|
const dims = vol.getDimensions();
|
|
75231
|
-
const center = this.canvasToVoxelCenter(
|
|
75232
|
-
const radii = this.getVoxelRadii(radius);
|
|
75801
|
+
const center = this.canvasToVoxelCenter(e.offsetX, e.offsetY, sliceIndex, axis);
|
|
75802
|
+
const radii = this.getVoxelRadii(radius, axis);
|
|
75233
75803
|
const bb = this.getBoundingBox(center, radii, dims);
|
|
75234
|
-
|
|
75235
|
-
const before = this.captureSliceSnapshots(vol, bb.minZ, bb.maxZ);
|
|
75236
|
-
// Write sphere
|
|
75804
|
+
this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
|
|
75237
75805
|
this.write3DSphereToBrush(vol, center, radii, bb, channel);
|
|
75238
|
-
|
|
75239
|
-
|
|
75240
|
-
|
|
75806
|
+
this.drawPreview(e.offsetX, e.offsetY, radius, false);
|
|
75807
|
+
this.refreshDisplay(layer, undefined, false);
|
|
75808
|
+
}
|
|
75809
|
+
/**
|
|
75810
|
+
* Handle pointer-up in sphereBrush mode — finalize stroke + push undo group
|
|
75811
|
+
* + single batched backend notify for all Z slices touched during the drag.
|
|
75812
|
+
*/
|
|
75813
|
+
onSphereBrushPointerUp() {
|
|
75814
|
+
if (!this.active || this.mode !== "brush")
|
|
75815
|
+
return;
|
|
75816
|
+
this.active = false;
|
|
75817
|
+
const layer = this.ctx.gui_states.layerChannel.layer;
|
|
75818
|
+
const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
|
|
75819
|
+
const axis = this.ctx.protectedData.axis;
|
|
75820
|
+
const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
|
|
75821
|
+
let vol;
|
|
75822
|
+
try {
|
|
75823
|
+
vol = this.callbacks.getVolumeForLayer(layer);
|
|
75824
|
+
}
|
|
75825
|
+
catch (_a) {
|
|
75826
|
+
this.finalizeStrokeState();
|
|
75827
|
+
return;
|
|
75828
|
+
}
|
|
75829
|
+
// Click-without-drag: write the single sphere now so a plain click still
|
|
75830
|
+
// paints something. onSphereBrushClick intentionally skipped this so the
|
|
75831
|
+
// user could wheel-adjust radius first.
|
|
75832
|
+
if (!this.dragMoved && this.strokeCenters.length > 0) {
|
|
75833
|
+
const sc = this.strokeCenters[0];
|
|
75834
|
+
const dims = vol.getDimensions();
|
|
75835
|
+
const center = this.canvasToVoxelCenter(sc.x, sc.y, sc.sliceIndex, axis);
|
|
75836
|
+
const radii = this.getVoxelRadii(radius, axis);
|
|
75837
|
+
const bb = this.getBoundingBox(center, radii, dims);
|
|
75838
|
+
this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
|
|
75839
|
+
this.write3DSphereToBrush(vol, center, radii, bb, channel);
|
|
75840
|
+
}
|
|
75841
|
+
const after = this.captureSliceSnapshots(vol, this.dragMinZ(), this.dragMaxZ());
|
|
75842
|
+
const deltas = this.buildUndoGroup(layer, this.dragBeforeSnapshots, after);
|
|
75241
75843
|
if (deltas.length > 0) {
|
|
75242
75844
|
this.callbacks.pushUndoGroup(deltas);
|
|
75243
75845
|
}
|
|
75244
|
-
|
|
75245
|
-
this.
|
|
75246
|
-
this.clearPreview();
|
|
75846
|
+
this.refreshDisplay(layer, deltas, true);
|
|
75847
|
+
this.finalizeStrokeState();
|
|
75247
75848
|
}
|
|
75248
75849
|
// ── Sphere Eraser Click/PointerUp ──────────────────────────────
|
|
75249
75850
|
/**
|
|
@@ -75251,13 +75852,17 @@ void main() {
|
|
|
75251
75852
|
* Initializes drag tracking for cumulative undo.
|
|
75252
75853
|
*/
|
|
75253
75854
|
onSphereEraserClick(e) {
|
|
75254
|
-
this.centerX = e.offsetX
|
|
75255
|
-
this.centerY = e.offsetY
|
|
75855
|
+
this.centerX = e.offsetX;
|
|
75856
|
+
this.centerY = e.offsetY;
|
|
75256
75857
|
this.centerSlice = this.ctx.nrrd_states.view.currentSliceIndex;
|
|
75858
|
+
this.lastDisplayX = this.centerX;
|
|
75859
|
+
this.lastDisplayY = this.centerY;
|
|
75860
|
+
this.lastSliceIndex = this.centerSlice;
|
|
75257
75861
|
this.active = true;
|
|
75258
75862
|
this.mode = "eraser";
|
|
75259
75863
|
this.dragMoved = false;
|
|
75260
75864
|
this.dragBeforeSnapshots.clear();
|
|
75865
|
+
this.strokeCenters = [{ x: this.centerX, y: this.centerY, sliceIndex: this.centerSlice }];
|
|
75261
75866
|
// Capture initial "before" snapshots for the first sphere position
|
|
75262
75867
|
const layer = this.ctx.gui_states.layerChannel.layer;
|
|
75263
75868
|
const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
|
|
@@ -75266,7 +75871,7 @@ void main() {
|
|
|
75266
75871
|
const dims = vol.getDimensions();
|
|
75267
75872
|
const axis = this.ctx.protectedData.axis;
|
|
75268
75873
|
const center = this.canvasToVoxelCenter(this.centerX, this.centerY, this.centerSlice, axis);
|
|
75269
|
-
const radii = this.getVoxelRadii(radius);
|
|
75874
|
+
const radii = this.getVoxelRadii(radius, axis);
|
|
75270
75875
|
const bb = this.getBoundingBox(center, radii, dims);
|
|
75271
75876
|
this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
|
|
75272
75877
|
}
|
|
@@ -75282,9 +75887,11 @@ void main() {
|
|
|
75282
75887
|
if (!this.active || this.mode !== "eraser")
|
|
75283
75888
|
return;
|
|
75284
75889
|
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
75890
|
const sliceIndex = this.ctx.nrrd_states.view.currentSliceIndex;
|
|
75891
|
+
this.lastDisplayX = e.offsetX;
|
|
75892
|
+
this.lastDisplayY = e.offsetY;
|
|
75893
|
+
this.lastSliceIndex = sliceIndex;
|
|
75894
|
+
this.strokeCenters.push({ x: e.offsetX, y: e.offsetY, sliceIndex });
|
|
75288
75895
|
const layer = this.ctx.gui_states.layerChannel.layer;
|
|
75289
75896
|
const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
|
|
75290
75897
|
const axis = this.ctx.protectedData.axis;
|
|
@@ -75297,17 +75904,18 @@ void main() {
|
|
|
75297
75904
|
return;
|
|
75298
75905
|
}
|
|
75299
75906
|
const dims = vol.getDimensions();
|
|
75300
|
-
const center = this.canvasToVoxelCenter(
|
|
75301
|
-
const radii = this.getVoxelRadii(radius);
|
|
75907
|
+
const center = this.canvasToVoxelCenter(e.offsetX, e.offsetY, sliceIndex, axis);
|
|
75908
|
+
const radii = this.getVoxelRadii(radius, axis);
|
|
75302
75909
|
const bb = this.getBoundingBox(center, radii, dims);
|
|
75303
75910
|
// Expand "before" snapshots to cover any new Z slices
|
|
75304
75911
|
this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
|
|
75305
75912
|
// Erase sphere at current position
|
|
75306
75913
|
this.erase3DSphereFromVolume(vol, center, radii, bb, channel);
|
|
75307
|
-
// Update preview position
|
|
75308
|
-
this.drawPreview(
|
|
75309
|
-
// Refresh display
|
|
75310
|
-
|
|
75914
|
+
// Update preview position (pass display/zoomed coords)
|
|
75915
|
+
this.drawPreview(e.offsetX, e.offsetY, radius, true);
|
|
75916
|
+
// Refresh display locally — skip backend notify to avoid per-frame HTTP.
|
|
75917
|
+
// onSphereEraserPointerUp batches all deltas into a single notify.
|
|
75918
|
+
this.refreshDisplay(layer, undefined, false);
|
|
75311
75919
|
}
|
|
75312
75920
|
/**
|
|
75313
75921
|
* Handle pointer-up in sphereEraser mode — finalize erase + push undo group.
|
|
@@ -75326,16 +75934,17 @@ void main() {
|
|
|
75326
75934
|
vol = this.callbacks.getVolumeForLayer(layer);
|
|
75327
75935
|
}
|
|
75328
75936
|
catch (_a) {
|
|
75329
|
-
this.
|
|
75330
|
-
this.dragBeforeSnapshots.clear();
|
|
75937
|
+
this.finalizeStrokeState();
|
|
75331
75938
|
return;
|
|
75332
75939
|
}
|
|
75333
|
-
//
|
|
75334
|
-
if (!this.dragMoved) {
|
|
75940
|
+
// Click-without-drag: erase at the click position (click-release behavior).
|
|
75941
|
+
if (!this.dragMoved && this.strokeCenters.length > 0) {
|
|
75942
|
+
const sc = this.strokeCenters[0];
|
|
75335
75943
|
const dims = vol.getDimensions();
|
|
75336
|
-
const center = this.canvasToVoxelCenter(
|
|
75337
|
-
const radii = this.getVoxelRadii(radius);
|
|
75944
|
+
const center = this.canvasToVoxelCenter(sc.x, sc.y, sc.sliceIndex, axis);
|
|
75945
|
+
const radii = this.getVoxelRadii(radius, axis);
|
|
75338
75946
|
const bb = this.getBoundingBox(center, radii, dims);
|
|
75947
|
+
this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
|
|
75339
75948
|
this.erase3DSphereFromVolume(vol, center, radii, bb, channel);
|
|
75340
75949
|
}
|
|
75341
75950
|
// Build undo group from cumulative "before" snapshots vs current state
|
|
@@ -75344,10 +75953,20 @@ void main() {
|
|
|
75344
75953
|
if (deltas.length > 0) {
|
|
75345
75954
|
this.callbacks.pushUndoGroup(deltas);
|
|
75346
75955
|
}
|
|
75347
|
-
// Refresh display and notify backend of ALL changed slices
|
|
75348
|
-
this.refreshDisplay(layer, deltas);
|
|
75956
|
+
// Refresh display and notify backend of ALL changed slices — single HTTP batch
|
|
75957
|
+
this.refreshDisplay(layer, deltas, true);
|
|
75958
|
+
this.finalizeStrokeState();
|
|
75959
|
+
}
|
|
75960
|
+
/**
|
|
75961
|
+
* Clear per-stroke state after pointerup or cancel. Kept as a helper so
|
|
75962
|
+
* brush/eraser pointerup and cancelActivePlacement all reset the same set
|
|
75963
|
+
* of fields — if a new drag-state field is added, only one place to update.
|
|
75964
|
+
*/
|
|
75965
|
+
finalizeStrokeState() {
|
|
75349
75966
|
this.clearPreview();
|
|
75350
75967
|
this.dragBeforeSnapshots.clear();
|
|
75968
|
+
this.strokeCenters = [];
|
|
75969
|
+
this.dragMoved = false;
|
|
75351
75970
|
}
|
|
75352
75971
|
/**
|
|
75353
75972
|
* Expand cumulative "before" snapshots to cover z range [minZ, maxZ].
|
|
@@ -75392,8 +76011,11 @@ void main() {
|
|
|
75392
76011
|
* @param changedDeltas - If provided, fire onMaskChanged for ALL changed
|
|
75393
76012
|
* slices (not just the current view slice) so the backend receives the
|
|
75394
76013
|
* full 3D sphere data for NII/GLTF export.
|
|
76014
|
+
* @param notifyBackend - When false, only refresh the local canvas and skip
|
|
76015
|
+
* the onMaskChanged callback. Used during drag to avoid per-frame HTTP;
|
|
76016
|
+
* pointerup then calls with `true` to send a single batched update.
|
|
75395
76017
|
*/
|
|
75396
|
-
refreshDisplay(layerId, changedDeltas) {
|
|
76018
|
+
refreshDisplay(layerId, changedDeltas, notifyBackend = true) {
|
|
75397
76019
|
// Re-render layer canvas from volume for the current slice
|
|
75398
76020
|
const target = this.ctx.protectedData.layerTargets.get(layerId);
|
|
75399
76021
|
if (target) {
|
|
@@ -75406,6 +76028,9 @@ void main() {
|
|
|
75406
76028
|
}
|
|
75407
76029
|
// Composite all layers to master canvas
|
|
75408
76030
|
this.callbacks.compositeAllLayers();
|
|
76031
|
+
// Skip backend notify during drag — pointerup batches a single notify for the whole stroke.
|
|
76032
|
+
if (!notifyBackend)
|
|
76033
|
+
return;
|
|
75409
76034
|
// Fire onMaskChanged for ALL changed slices (not just the current view slice)
|
|
75410
76035
|
// This ensures the backend receives the full 3D sphere data for export.
|
|
75411
76036
|
try {
|
|
@@ -75434,6 +76059,23 @@ void main() {
|
|
|
75434
76059
|
get isActive() {
|
|
75435
76060
|
return this.active;
|
|
75436
76061
|
}
|
|
76062
|
+
/**
|
|
76063
|
+
* Defensively cancel any in-progress sphere placement and clear the preview.
|
|
76064
|
+
*
|
|
76065
|
+
* Called when crosshair mode is entered so any lingering sphere preview
|
|
76066
|
+
* (e.g. red dashed eraser outline) is wiped immediately. In normal flow
|
|
76067
|
+
* `leftButtonDown === true` blocks crosshair toggle, so this shouldn't
|
|
76068
|
+
* fire mid-stroke — but if it ever does, we drop the drag snapshot
|
|
76069
|
+
* rather than committing a partial undo entry.
|
|
76070
|
+
*/
|
|
76071
|
+
cancelActivePlacement() {
|
|
76072
|
+
if (!this.active) {
|
|
76073
|
+
this.clearPreview();
|
|
76074
|
+
return;
|
|
76075
|
+
}
|
|
76076
|
+
this.active = false;
|
|
76077
|
+
this.finalizeStrokeState();
|
|
76078
|
+
}
|
|
75437
76079
|
}
|
|
75438
76080
|
|
|
75439
76081
|
/**
|
|
@@ -75538,6 +76180,8 @@ void main() {
|
|
|
75538
76180
|
getVolumeForLayer: (layer) => this.renderer.getVolumeForLayer(layer),
|
|
75539
76181
|
pushUndoDelta: (delta) => this.undoManager.push(delta),
|
|
75540
76182
|
getEraserUrls: () => this.eraserUrls,
|
|
76183
|
+
renderSliceToCanvas: (layer, axis, sliceIndex, buffer, ctx, w, h) => this.renderer.renderSliceToCanvas(layer, axis, sliceIndex, buffer, ctx, w, h),
|
|
76184
|
+
getOrCreateSliceBuffer: (axis) => this.renderer.getOrCreateSliceBuffer(axis),
|
|
75541
76185
|
});
|
|
75542
76186
|
this.sphereBrushTool = new SphereBrushTool(toolCtx, {
|
|
75543
76187
|
getVolumeForLayer: (layer) => this.renderer.getVolumeForLayer(layer),
|
|
@@ -75556,6 +76200,7 @@ void main() {
|
|
|
75556
76200
|
container: this.container,
|
|
75557
76201
|
canvas: this.state.protectedData.canvases.drawingCanvas,
|
|
75558
76202
|
onModeChange: (prevMode, newMode) => {
|
|
76203
|
+
var _a;
|
|
75559
76204
|
// Use string comparison to avoid TypeScript narrowing issues
|
|
75560
76205
|
const prev = prevMode;
|
|
75561
76206
|
const next = newMode;
|
|
@@ -75568,6 +76213,10 @@ void main() {
|
|
|
75568
76213
|
}
|
|
75569
76214
|
if (next === 'crosshair') {
|
|
75570
76215
|
this.state.protectedData.isDrawing = false;
|
|
76216
|
+
// Wipe any lingering sphere preview (red dashed eraser outline /
|
|
76217
|
+
// filled brush preview) when the user toggles into crosshair from
|
|
76218
|
+
// sphereBrush/sphereEraser. Safe no-op when no placement is active.
|
|
76219
|
+
(_a = this.sphereBrushTool) === null || _a === void 0 ? void 0 : _a.cancelActivePlacement();
|
|
75571
76220
|
}
|
|
75572
76221
|
}
|
|
75573
76222
|
});
|
|
@@ -75666,9 +76315,14 @@ void main() {
|
|
|
75666
76315
|
if (this.drawingTool.isActive || this.panTool.isActive) {
|
|
75667
76316
|
this.drawingPrameters.handleOnDrawingMouseMove(e);
|
|
75668
76317
|
}
|
|
75669
|
-
// Drag
|
|
75670
|
-
if (this.sphereBrushTool.isActive
|
|
75671
|
-
this.
|
|
76318
|
+
// Drag: route move to sphereBrushTool for both brush and eraser
|
|
76319
|
+
if (this.sphereBrushTool.isActive) {
|
|
76320
|
+
if (this.state.gui_states.mode.sphereEraser) {
|
|
76321
|
+
this.sphereBrushTool.onSphereEraserMove(e);
|
|
76322
|
+
}
|
|
76323
|
+
else if (this.state.gui_states.mode.sphereBrush) {
|
|
76324
|
+
this.sphereBrushTool.onSphereBrushMove(e);
|
|
76325
|
+
}
|
|
75672
76326
|
}
|
|
75673
76327
|
});
|
|
75674
76328
|
this.eventRouter.setPointerUpHandler((e) => {
|
|
@@ -75967,6 +76621,11 @@ void main() {
|
|
|
75967
76621
|
const ctx = this.state.protectedData.ctxes.emptyCtx;
|
|
75968
76622
|
const w = this.state.protectedData.canvases.emptyCanvas.width;
|
|
75969
76623
|
const h = this.state.protectedData.canvases.emptyCanvas.height;
|
|
76624
|
+
// Bake step: downscale layer canvas (display resolution) to emptyCanvas
|
|
76625
|
+
// (voxel resolution) before label extraction. Use nearest-neighbor
|
|
76626
|
+
// (smoothing=false) so voxel occupancy is determined directly by the
|
|
76627
|
+
// drawn pixels on the layer canvas, without blending that could shift
|
|
76628
|
+
// the alpha>=128 threshold decision in setSliceLabelsFromImageData.
|
|
75970
76629
|
ctx.imageSmoothingEnabled = false;
|
|
75971
76630
|
ctx.drawImage(canvas, 0, 0, w, h);
|
|
75972
76631
|
}
|
|
@@ -77673,11 +78332,15 @@ void main() {
|
|
|
77673
78332
|
this.state.gui_states.mode.sphereBrush = true;
|
|
77674
78333
|
this.dragOperator.removeDragMode();
|
|
77675
78334
|
(_a = this.drawCore.eventRouter) === null || _a === void 0 ? void 0 : _a.setGuiTool('sphereBrush');
|
|
78335
|
+
this.state.protectedData.canvases.drawingCanvas.style.cursor =
|
|
78336
|
+
this.state.gui_states.viewConfig.defaultPaintCursor;
|
|
77676
78337
|
break;
|
|
77677
78338
|
case "sphereEraser":
|
|
77678
78339
|
this.state.gui_states.mode.sphereEraser = true;
|
|
77679
78340
|
this.dragOperator.removeDragMode();
|
|
77680
78341
|
(_b = this.drawCore.eventRouter) === null || _b === void 0 ? void 0 : _b.setGuiTool('sphereEraser');
|
|
78342
|
+
this.state.protectedData.canvases.drawingCanvas.style.cursor =
|
|
78343
|
+
this.state.gui_states.viewConfig.defaultPaintCursor;
|
|
77681
78344
|
break;
|
|
77682
78345
|
}
|
|
77683
78346
|
// Restore drag mode when leaving sphereBrush/sphereEraser
|
|
@@ -78650,7 +79313,7 @@ void main() {
|
|
|
78650
79313
|
}
|
|
78651
79314
|
|
|
78652
79315
|
// import * as kiwrious from "copper3d_plugin_heart_k";
|
|
78653
|
-
const REVISION = "v3.
|
|
79316
|
+
const REVISION = "v3.4.1-beta";
|
|
78654
79317
|
console.log(`%cCopper3D Visualisation %cBeta:${REVISION}`, "padding: 3px;color:white; background:#023047", "padding: 3px;color:white; background:#f50a25");
|
|
78655
79318
|
|
|
78656
79319
|
exports.CHANNEL_COLORS = CHANNEL_COLORS;
|