copper3d 3.3.2 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Utils/segmentation/DrawToolCore.js +20 -3
- package/dist/Utils/segmentation/DrawToolCore.js.map +1 -1
- package/dist/Utils/segmentation/NrrdTools.d.ts +19 -0
- package/dist/Utils/segmentation/NrrdTools.js +40 -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.js +14 -7
- 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 +808 -130
- package/dist/bundle.umd.js +808 -130
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/types/Utils/segmentation/NrrdTools.d.ts +19 -0
- 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/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.
|
|
73563
73732
|
*
|
|
73564
|
-
* Uses
|
|
73733
|
+
* Uses marching-squares to extract voxel-truthful Path2D contours per
|
|
73734
|
+
* label, then `ctx.fill()` them at the display canvas resolution. This
|
|
73735
|
+
* eliminates the bilinear-upscale blur and zoom-dependent shape drift
|
|
73736
|
+
* that plagued the old putImageData → drawImage pipeline.
|
|
73737
|
+
*
|
|
73738
|
+
* The `buffer` parameter is kept for backward-compatible signature but
|
|
73739
|
+
* is no longer used on this path — callers may pass any valid ImageData.
|
|
73565
73740
|
*/
|
|
73566
|
-
renderSliceToCanvas(layer, axis, sliceIndex,
|
|
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;
|
|
@@ -73945,9 +74140,9 @@ void main() {
|
|
|
73945
74140
|
}
|
|
73946
74141
|
}
|
|
73947
74142
|
if (this.contrastEnabled && this.keyboardSettings.contrast.includes(ev.key)) {
|
|
73948
|
-
// Block contrast state when crosshair, draw, or sphere is active (mutual exclusion)
|
|
74143
|
+
// Block contrast state when crosshair, draw, or any sphere-family tool is active (mutual exclusion)
|
|
73949
74144
|
if (!this.state.crosshairEnabled && this.mode !== 'draw'
|
|
73950
|
-
&& this.guiTool
|
|
74145
|
+
&& !SPHERE_TOOLS.has(this.guiTool)) {
|
|
73951
74146
|
this.state.ctrlHeld = true;
|
|
73952
74147
|
}
|
|
73953
74148
|
}
|
|
@@ -74571,6 +74766,10 @@ void main() {
|
|
|
74571
74766
|
* Extracted from DrawToolCore.paintOnCanvas() closure (Phase 3).
|
|
74572
74767
|
* Handles left-click drawing operations: pencil stroke + fill, brush strokes,
|
|
74573
74768
|
* eraser, and undo snapshot capture/push.
|
|
74769
|
+
*
|
|
74770
|
+
* Phase B: Brush mode now writes voxels directly during mousemove and
|
|
74771
|
+
* re-renders via marching squares, eliminating the visual "snap" between
|
|
74772
|
+
* the smooth canvas stroke and the voxel-based post-release render.
|
|
74574
74773
|
*/
|
|
74575
74774
|
class DrawingTool extends BaseTool {
|
|
74576
74775
|
constructor(ctx, callbacks) {
|
|
@@ -74587,6 +74786,8 @@ void main() {
|
|
|
74587
74786
|
this.preDrawSlice = null;
|
|
74588
74787
|
this.preDrawAxis = "z";
|
|
74589
74788
|
this.preDrawSliceIndex = 0;
|
|
74789
|
+
/** Previous voxel-space position for Bresenham interpolation (brush mode) */
|
|
74790
|
+
this.prevVoxel = null;
|
|
74590
74791
|
this.callbacks = callbacks;
|
|
74591
74792
|
}
|
|
74592
74793
|
/** Whether a draw operation is currently active */
|
|
@@ -74606,6 +74807,7 @@ void main() {
|
|
|
74606
74807
|
this.isPainting = false;
|
|
74607
74808
|
this.drawingLines = [];
|
|
74608
74809
|
this.clearArcFn = clearArcFn;
|
|
74810
|
+
this.prevVoxel = null;
|
|
74609
74811
|
}
|
|
74610
74812
|
/**
|
|
74611
74813
|
* Called on left-click pointerdown in draw mode.
|
|
@@ -74633,6 +74835,22 @@ void main() {
|
|
|
74633
74835
|
this.ctx.nrrd_states.interaction.drawStartPos.y = e.offsetY;
|
|
74634
74836
|
// Capture pre-draw slice snapshot for undo
|
|
74635
74837
|
this.capturePreDrawSnapshot();
|
|
74838
|
+
// Brush mode: initialize voxel tracking and write first dot
|
|
74839
|
+
if (!this.ctx.gui_states.mode.pencil && !this.ctx.gui_states.mode.eraser) {
|
|
74840
|
+
const voxel = this.canvasToVoxel3D(e.offsetX, e.offsetY);
|
|
74841
|
+
this.prevVoxel = voxel;
|
|
74842
|
+
try {
|
|
74843
|
+
const vol = this.callbacks.getVolumeForLayer(this.ctx.gui_states.layerChannel.layer);
|
|
74844
|
+
const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
|
|
74845
|
+
const axis = this.ctx.protectedData.axis;
|
|
74846
|
+
const { rH, rV } = this.getVoxelBrushRadius();
|
|
74847
|
+
this.paintVoxelEllipse(vol, voxel, rH, rV, channel, axis);
|
|
74848
|
+
this.refreshLayerFromVolume();
|
|
74849
|
+
}
|
|
74850
|
+
catch (_a) {
|
|
74851
|
+
// Volume not ready
|
|
74852
|
+
}
|
|
74853
|
+
}
|
|
74636
74854
|
}
|
|
74637
74855
|
/**
|
|
74638
74856
|
* Called on pointermove during an active drawing operation.
|
|
@@ -74646,10 +74864,16 @@ void main() {
|
|
|
74646
74864
|
this.ctx.nrrd_states.flags.stepClear = 1;
|
|
74647
74865
|
(_a = this.clearArcFn) === null || _a === void 0 ? void 0 : _a.call(this, e.offsetX, e.offsetY, this.ctx.gui_states.drawing.brushAndEraserSize);
|
|
74648
74866
|
}
|
|
74649
|
-
else {
|
|
74867
|
+
else if (this.ctx.gui_states.mode.pencil) {
|
|
74868
|
+
// Pencil mode: accumulate points and draw visual lines on canvas
|
|
74650
74869
|
this.drawingLines.push({ x: e.offsetX, y: e.offsetY });
|
|
74651
74870
|
this.paintOnCanvasLayer(e.offsetX, e.offsetY);
|
|
74652
74871
|
}
|
|
74872
|
+
else {
|
|
74873
|
+
// Brush mode: write voxels directly + re-render via marching squares
|
|
74874
|
+
this.drawingLines.push({ x: e.offsetX, y: e.offsetY });
|
|
74875
|
+
this.paintBrushVoxelMove(e.offsetX, e.offsetY);
|
|
74876
|
+
}
|
|
74653
74877
|
}
|
|
74654
74878
|
}
|
|
74655
74879
|
/**
|
|
@@ -74662,11 +74886,9 @@ void main() {
|
|
|
74662
74886
|
ctx.closePath();
|
|
74663
74887
|
if (!this.ctx.gui_states.mode.eraser) {
|
|
74664
74888
|
if (this.ctx.gui_states.mode.pencil) {
|
|
74665
|
-
//
|
|
74889
|
+
// Pencil mode: fill polygon then bake to voxels
|
|
74666
74890
|
canvas.width = canvas.width;
|
|
74667
|
-
// Redraw previous layer data from volume
|
|
74668
74891
|
this.redrawPreviousImageToLayerCtx(ctx);
|
|
74669
|
-
// Draw new pencil strokes on current layer canvas
|
|
74670
74892
|
ctx.beginPath();
|
|
74671
74893
|
ctx.moveTo(this.drawingLines[0].x, this.drawingLines[0].y);
|
|
74672
74894
|
for (let i = 1; i < this.drawingLines.length; i++) {
|
|
@@ -74676,12 +74898,26 @@ void main() {
|
|
|
74676
74898
|
ctx.lineWidth = 1;
|
|
74677
74899
|
ctx.fillStyle = this.ctx.gui_states.drawing.fillColor;
|
|
74678
74900
|
ctx.fill();
|
|
74679
|
-
// Composite ALL layers to master (not just current layer)
|
|
74680
74901
|
this.callbacks.compositeAllLayers();
|
|
74902
|
+
// Pencil still needs canvas→voxel bake
|
|
74903
|
+
this.callbacks.syncLayerSliceData(this.ctx.nrrd_states.view.currentSliceIndex, this.ctx.gui_states.layerChannel.layer);
|
|
74904
|
+
// Re-render from voxels to eliminate pencil snap
|
|
74905
|
+
this.refreshLayerFromVolume();
|
|
74681
74906
|
}
|
|
74907
|
+
else {
|
|
74908
|
+
// Brush mode: voxels already written during mousemove
|
|
74909
|
+
// Just re-render final state and composite
|
|
74910
|
+
this.refreshLayerFromVolume();
|
|
74911
|
+
}
|
|
74912
|
+
}
|
|
74913
|
+
else {
|
|
74914
|
+
// Eraser mode: still needs canvas→voxel bake
|
|
74915
|
+
this.callbacks.syncLayerSliceData(this.ctx.nrrd_states.view.currentSliceIndex, this.ctx.gui_states.layerChannel.layer);
|
|
74916
|
+
// Re-render from voxels for eraser too
|
|
74917
|
+
this.refreshLayerFromVolume();
|
|
74682
74918
|
}
|
|
74683
|
-
this.callbacks.syncLayerSliceData(this.ctx.nrrd_states.view.currentSliceIndex, this.ctx.gui_states.layerChannel.layer);
|
|
74684
74919
|
this.isPainting = false;
|
|
74920
|
+
this.prevVoxel = null;
|
|
74685
74921
|
// Push undo delta
|
|
74686
74922
|
this.pushUndoDelta();
|
|
74687
74923
|
}
|
|
@@ -74692,6 +74928,7 @@ void main() {
|
|
|
74692
74928
|
*/
|
|
74693
74929
|
onPointerLeave() {
|
|
74694
74930
|
this.isPainting = false;
|
|
74931
|
+
this.prevVoxel = null;
|
|
74695
74932
|
if (!this.leftClicked)
|
|
74696
74933
|
return false;
|
|
74697
74934
|
this.leftClicked = false;
|
|
@@ -74702,11 +74939,6 @@ void main() {
|
|
|
74702
74939
|
/**
|
|
74703
74940
|
* Create a self-managing mouseover/mouseout/mousemove handler
|
|
74704
74941
|
* that tracks brush hover position for the preview circle.
|
|
74705
|
-
*
|
|
74706
|
-
* The returned function should be registered on drawingCanvas for
|
|
74707
|
-
* "mouseover" and "mouseout" events. It adds/removes a "mousemove"
|
|
74708
|
-
* listener on itself to keep mouseOverX/Y up-to-date while the
|
|
74709
|
-
* cursor is inside the canvas.
|
|
74710
74942
|
*/
|
|
74711
74943
|
createBrushTrackingHandler() {
|
|
74712
74944
|
const handler = (e) => {
|
|
@@ -74730,8 +74962,6 @@ void main() {
|
|
|
74730
74962
|
}
|
|
74731
74963
|
/**
|
|
74732
74964
|
* Render brush circle preview on the drawing context.
|
|
74733
|
-
* Called from the start() render loop when in draw mode and not
|
|
74734
|
-
* actively painting. Skipped in pencil/eraser mode.
|
|
74735
74965
|
*/
|
|
74736
74966
|
renderBrushPreview(ctx, width, height) {
|
|
74737
74967
|
if (this.ctx.gui_states.mode.pencil ||
|
|
@@ -74746,6 +74976,195 @@ void main() {
|
|
|
74746
74976
|
ctx.strokeStyle = this.ctx.gui_states.drawing.brushColor;
|
|
74747
74977
|
ctx.stroke();
|
|
74748
74978
|
}
|
|
74979
|
+
// ── Brush mode: direct voxel write ────────────────────────────
|
|
74980
|
+
/**
|
|
74981
|
+
* Convert display (zoomed) coordinates to 3D voxel coordinates.
|
|
74982
|
+
* Same logic as SphereBrushTool.canvasToVoxelCenter.
|
|
74983
|
+
*/
|
|
74984
|
+
canvasToVoxel3D(displayX, displayY) {
|
|
74985
|
+
const nrrd = this.ctx.nrrd_states;
|
|
74986
|
+
const axis = this.ctx.protectedData.axis;
|
|
74987
|
+
const cw = nrrd.view.changedWidth;
|
|
74988
|
+
const ch = nrrd.view.changedHeight;
|
|
74989
|
+
const sliceIndex = nrrd.view.currentSliceIndex;
|
|
74990
|
+
switch (axis) {
|
|
74991
|
+
case 'z':
|
|
74992
|
+
return {
|
|
74993
|
+
x: displayX * nrrd.image.nrrd_x_pixel / cw,
|
|
74994
|
+
y: displayY * nrrd.image.nrrd_y_pixel / ch,
|
|
74995
|
+
z: sliceIndex,
|
|
74996
|
+
};
|
|
74997
|
+
case 'y':
|
|
74998
|
+
return {
|
|
74999
|
+
x: displayX * nrrd.image.nrrd_x_pixel / cw,
|
|
75000
|
+
y: sliceIndex,
|
|
75001
|
+
z: (ch - displayY) * nrrd.image.nrrd_z_pixel / ch,
|
|
75002
|
+
};
|
|
75003
|
+
case 'x':
|
|
75004
|
+
return {
|
|
75005
|
+
x: sliceIndex,
|
|
75006
|
+
y: displayY * nrrd.image.nrrd_y_pixel / ch,
|
|
75007
|
+
z: displayX * nrrd.image.nrrd_z_pixel / cw,
|
|
75008
|
+
};
|
|
75009
|
+
}
|
|
75010
|
+
}
|
|
75011
|
+
/**
|
|
75012
|
+
* Get voxel-space brush radius for visible axes.
|
|
75013
|
+
* Converts display-pixel brush size to voxel units.
|
|
75014
|
+
*/
|
|
75015
|
+
getVoxelBrushRadius() {
|
|
75016
|
+
const nrrd = this.ctx.nrrd_states;
|
|
75017
|
+
const axis = this.ctx.protectedData.axis;
|
|
75018
|
+
const cw = nrrd.view.changedWidth;
|
|
75019
|
+
const ch = nrrd.view.changedHeight;
|
|
75020
|
+
const displayRadius = this.ctx.gui_states.drawing.brushAndEraserSize / 2;
|
|
75021
|
+
switch (axis) {
|
|
75022
|
+
case 'z':
|
|
75023
|
+
return {
|
|
75024
|
+
rH: displayRadius * nrrd.image.nrrd_x_pixel / cw,
|
|
75025
|
+
rV: displayRadius * nrrd.image.nrrd_y_pixel / ch,
|
|
75026
|
+
};
|
|
75027
|
+
case 'y':
|
|
75028
|
+
return {
|
|
75029
|
+
rH: displayRadius * nrrd.image.nrrd_x_pixel / cw,
|
|
75030
|
+
rV: displayRadius * nrrd.image.nrrd_z_pixel / ch,
|
|
75031
|
+
};
|
|
75032
|
+
case 'x':
|
|
75033
|
+
return {
|
|
75034
|
+
rH: displayRadius * nrrd.image.nrrd_z_pixel / cw,
|
|
75035
|
+
rV: displayRadius * nrrd.image.nrrd_y_pixel / ch,
|
|
75036
|
+
};
|
|
75037
|
+
}
|
|
75038
|
+
}
|
|
75039
|
+
/**
|
|
75040
|
+
* Paint a 2D ellipse of voxels on the current slice.
|
|
75041
|
+
* Only the two visible axes are iterated; the slice-direction axis is fixed.
|
|
75042
|
+
*/
|
|
75043
|
+
paintVoxelEllipse(vol, center, rH, rV, label, axis) {
|
|
75044
|
+
const dims = vol.getDimensions();
|
|
75045
|
+
switch (axis) {
|
|
75046
|
+
case 'z': {
|
|
75047
|
+
const minX = Math.max(0, Math.floor(center.x - rH));
|
|
75048
|
+
const maxX = Math.min(dims.width - 1, Math.ceil(center.x + rH));
|
|
75049
|
+
const minY = Math.max(0, Math.floor(center.y - rV));
|
|
75050
|
+
const maxY = Math.min(dims.height - 1, Math.ceil(center.y + rV));
|
|
75051
|
+
const z = Math.round(center.z);
|
|
75052
|
+
for (let y = minY; y <= maxY; y++) {
|
|
75053
|
+
for (let x = minX; x <= maxX; x++) {
|
|
75054
|
+
const dx = rH > 0 ? (x - center.x) / rH : 0;
|
|
75055
|
+
const dy = rV > 0 ? (y - center.y) / rV : 0;
|
|
75056
|
+
if (dx * dx + dy * dy <= 1.0) {
|
|
75057
|
+
vol.setVoxel(x, y, z, label);
|
|
75058
|
+
}
|
|
75059
|
+
}
|
|
75060
|
+
}
|
|
75061
|
+
break;
|
|
75062
|
+
}
|
|
75063
|
+
case 'y': {
|
|
75064
|
+
const minX = Math.max(0, Math.floor(center.x - rH));
|
|
75065
|
+
const maxX = Math.min(dims.width - 1, Math.ceil(center.x + rH));
|
|
75066
|
+
const minZ = Math.max(0, Math.floor(center.z - rV));
|
|
75067
|
+
const maxZ = Math.min(dims.depth - 1, Math.ceil(center.z + rV));
|
|
75068
|
+
const y = Math.round(center.y);
|
|
75069
|
+
for (let z = minZ; z <= maxZ; z++) {
|
|
75070
|
+
for (let x = minX; x <= maxX; x++) {
|
|
75071
|
+
const dx = rH > 0 ? (x - center.x) / rH : 0;
|
|
75072
|
+
const dz = rV > 0 ? (z - center.z) / rV : 0;
|
|
75073
|
+
if (dx * dx + dz * dz <= 1.0) {
|
|
75074
|
+
vol.setVoxel(x, y, z, label);
|
|
75075
|
+
}
|
|
75076
|
+
}
|
|
75077
|
+
}
|
|
75078
|
+
break;
|
|
75079
|
+
}
|
|
75080
|
+
case 'x': {
|
|
75081
|
+
const minZ = Math.max(0, Math.floor(center.z - rH));
|
|
75082
|
+
const maxZ = Math.min(dims.depth - 1, Math.ceil(center.z + rH));
|
|
75083
|
+
const minY = Math.max(0, Math.floor(center.y - rV));
|
|
75084
|
+
const maxY = Math.min(dims.height - 1, Math.ceil(center.y + rV));
|
|
75085
|
+
const x = Math.round(center.x);
|
|
75086
|
+
for (let yy = minY; yy <= maxY; yy++) {
|
|
75087
|
+
for (let zz = minZ; zz <= maxZ; zz++) {
|
|
75088
|
+
const dz = rH > 0 ? (zz - center.z) / rH : 0;
|
|
75089
|
+
const dy = rV > 0 ? (yy - center.y) / rV : 0;
|
|
75090
|
+
if (dz * dz + dy * dy <= 1.0) {
|
|
75091
|
+
vol.setVoxel(x, yy, zz, label);
|
|
75092
|
+
}
|
|
75093
|
+
}
|
|
75094
|
+
}
|
|
75095
|
+
break;
|
|
75096
|
+
}
|
|
75097
|
+
}
|
|
75098
|
+
}
|
|
75099
|
+
/**
|
|
75100
|
+
* Brush mode mousemove: write voxels along stroke segment, then re-render.
|
|
75101
|
+
* Uses linear interpolation between prev and current positions to ensure
|
|
75102
|
+
* no gaps when the mouse moves fast.
|
|
75103
|
+
*/
|
|
75104
|
+
paintBrushVoxelMove(displayX, displayY) {
|
|
75105
|
+
try {
|
|
75106
|
+
const vol = this.callbacks.getVolumeForLayer(this.ctx.gui_states.layerChannel.layer);
|
|
75107
|
+
const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
|
|
75108
|
+
const axis = this.ctx.protectedData.axis;
|
|
75109
|
+
const { rH, rV } = this.getVoxelBrushRadius();
|
|
75110
|
+
const current = this.canvasToVoxel3D(displayX, displayY);
|
|
75111
|
+
if (this.prevVoxel) {
|
|
75112
|
+
// Interpolate from prev to current to fill gaps
|
|
75113
|
+
const prev = this.prevVoxel;
|
|
75114
|
+
let steps;
|
|
75115
|
+
switch (axis) {
|
|
75116
|
+
case 'z':
|
|
75117
|
+
steps = Math.max(Math.abs(current.x - prev.x), Math.abs(current.y - prev.y));
|
|
75118
|
+
break;
|
|
75119
|
+
case 'y':
|
|
75120
|
+
steps = Math.max(Math.abs(current.x - prev.x), Math.abs(current.z - prev.z));
|
|
75121
|
+
break;
|
|
75122
|
+
case 'x':
|
|
75123
|
+
steps = Math.max(Math.abs(current.z - prev.z), Math.abs(current.y - prev.y));
|
|
75124
|
+
break;
|
|
75125
|
+
}
|
|
75126
|
+
steps = Math.max(1, Math.ceil(steps));
|
|
75127
|
+
for (let i = 0; i <= steps; i++) {
|
|
75128
|
+
const t = steps === 0 ? 0 : i / steps;
|
|
75129
|
+
const pt = {
|
|
75130
|
+
x: prev.x + (current.x - prev.x) * t,
|
|
75131
|
+
y: prev.y + (current.y - prev.y) * t,
|
|
75132
|
+
z: prev.z + (current.z - prev.z) * t,
|
|
75133
|
+
};
|
|
75134
|
+
this.paintVoxelEllipse(vol, pt, rH, rV, channel, axis);
|
|
75135
|
+
}
|
|
75136
|
+
}
|
|
75137
|
+
else {
|
|
75138
|
+
this.paintVoxelEllipse(vol, current, rH, rV, channel, axis);
|
|
75139
|
+
}
|
|
75140
|
+
this.prevVoxel = current;
|
|
75141
|
+
// Re-render the layer canvas from volume (marching squares)
|
|
75142
|
+
this.refreshLayerFromVolume();
|
|
75143
|
+
}
|
|
75144
|
+
catch (_a) {
|
|
75145
|
+
// Volume not ready — fall back to canvas drawing
|
|
75146
|
+
this.paintOnCanvasLayer(displayX, displayY);
|
|
75147
|
+
}
|
|
75148
|
+
}
|
|
75149
|
+
/**
|
|
75150
|
+
* Re-render the active layer's canvas from MaskVolume via marching squares,
|
|
75151
|
+
* then composite all layers to master.
|
|
75152
|
+
*/
|
|
75153
|
+
refreshLayerFromVolume() {
|
|
75154
|
+
const layer = this.ctx.gui_states.layerChannel.layer;
|
|
75155
|
+
const target = this.ctx.protectedData.layerTargets.get(layer);
|
|
75156
|
+
if (!target)
|
|
75157
|
+
return;
|
|
75158
|
+
const { ctx, canvas } = target;
|
|
75159
|
+
canvas.width = canvas.width; // clear
|
|
75160
|
+
const axis = this.ctx.protectedData.axis;
|
|
75161
|
+
const buffer = this.callbacks.getOrCreateSliceBuffer(axis);
|
|
75162
|
+
if (buffer) {
|
|
75163
|
+
this.callbacks.renderSliceToCanvas(layer, axis, this.ctx.nrrd_states.view.currentSliceIndex, buffer, ctx, this.ctx.nrrd_states.view.changedWidth, this.ctx.nrrd_states.view.changedHeight);
|
|
75164
|
+
}
|
|
75165
|
+
this.callbacks.compositeAllLayers();
|
|
75166
|
+
this.ctx.protectedData.mainPreSlices.mesh.material.map.needsUpdate = true;
|
|
75167
|
+
}
|
|
74749
75168
|
// ── Private helpers ────────────────────────────────────────
|
|
74750
75169
|
/** Capture pre-draw slice snapshot for undo */
|
|
74751
75170
|
capturePreDrawSnapshot() {
|
|
@@ -74782,27 +75201,18 @@ void main() {
|
|
|
74782
75201
|
}
|
|
74783
75202
|
/**
|
|
74784
75203
|
* Redraws persisted layer data onto ctx before new pencil fill.
|
|
74785
|
-
*
|
|
75204
|
+
* Delegates to the host's vector renderSliceToCanvas for consistency.
|
|
74786
75205
|
*/
|
|
74787
75206
|
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
|
-
}
|
|
75207
|
+
const axis = this.ctx.protectedData.axis;
|
|
75208
|
+
const W = this.ctx.nrrd_states.view.changedWidth;
|
|
75209
|
+
const H = this.ctx.nrrd_states.view.changedHeight;
|
|
75210
|
+
const buffer = this.callbacks.getOrCreateSliceBuffer(axis);
|
|
75211
|
+
if (!buffer)
|
|
75212
|
+
return;
|
|
75213
|
+
this.callbacks.renderSliceToCanvas(this.ctx.gui_states.layerChannel.layer, axis, this.ctx.nrrd_states.view.currentSliceIndex, buffer, ctx, W, H);
|
|
74804
75214
|
}
|
|
74805
|
-
/** Draw a line segment on a layer canvas */
|
|
75215
|
+
/** Draw a line segment on a layer canvas (pencil mode only) */
|
|
74806
75216
|
drawLinesOnLayer(ctx, x, y) {
|
|
74807
75217
|
ctx.beginPath();
|
|
74808
75218
|
ctx.moveTo(this.ctx.nrrd_states.interaction.drawStartPos.x, this.ctx.nrrd_states.interaction.drawStartPos.y);
|
|
@@ -74818,17 +75228,13 @@ void main() {
|
|
|
74818
75228
|
ctx.stroke();
|
|
74819
75229
|
ctx.closePath();
|
|
74820
75230
|
}
|
|
74821
|
-
/** Paint a segment on the current layer canvas and composite to master */
|
|
75231
|
+
/** Paint a segment on the current layer canvas and composite to master (pencil mode) */
|
|
74822
75232
|
paintOnCanvasLayer(x, y) {
|
|
74823
75233
|
const { ctx } = this.callbacks.setCurrentLayer();
|
|
74824
|
-
// Draw only on the current layer canvas (not master directly)
|
|
74825
75234
|
this.drawLinesOnLayer(ctx, x, y);
|
|
74826
|
-
// Composite all layers to master to preserve other layers' data
|
|
74827
75235
|
this.callbacks.compositeAllLayers();
|
|
74828
|
-
// Reset drawing start position to current position
|
|
74829
75236
|
this.ctx.nrrd_states.interaction.drawStartPos.x = x;
|
|
74830
75237
|
this.ctx.nrrd_states.interaction.drawStartPos.y = y;
|
|
74831
|
-
// Flag the map as needing updating
|
|
74832
75238
|
this.ctx.protectedData.mainPreSlices.mesh.material.map.needsUpdate = true;
|
|
74833
75239
|
}
|
|
74834
75240
|
}
|
|
@@ -74968,7 +75374,11 @@ void main() {
|
|
|
74968
75374
|
class SphereBrushTool extends BaseTool {
|
|
74969
75375
|
constructor(ctx, callbacks) {
|
|
74970
75376
|
super(ctx);
|
|
74971
|
-
/**
|
|
75377
|
+
/**
|
|
75378
|
+
* Recorded sphere center in display (zoomed) coordinates.
|
|
75379
|
+
* Used for both preview redraw and voxel calculation — the voxel conversion
|
|
75380
|
+
* uses `changedWidth/Height` as the display scale, so no mm detour is needed.
|
|
75381
|
+
*/
|
|
74972
75382
|
this.centerX = 0;
|
|
74973
75383
|
this.centerY = 0;
|
|
74974
75384
|
this.centerSlice = 0;
|
|
@@ -74976,10 +75386,24 @@ void main() {
|
|
|
74976
75386
|
this.active = false;
|
|
74977
75387
|
/** Current operation mode for the active placement */
|
|
74978
75388
|
this.mode = "brush";
|
|
74979
|
-
/** Cumulative "before" snapshots for drag
|
|
75389
|
+
/** Cumulative "before" snapshots for drag undo (z-index → slice data) — used by both brush and eraser drag */
|
|
74980
75390
|
this.dragBeforeSnapshots = new Map();
|
|
74981
|
-
/** Whether a drag
|
|
75391
|
+
/** Whether a drag has actually moved (vs. simple click-release) */
|
|
74982
75392
|
this.dragMoved = false;
|
|
75393
|
+
/**
|
|
75394
|
+
* Latest cursor position in display (zoomed) coords. Used by the wheel
|
|
75395
|
+
* handler to redraw the preview at the cursor, not the original mousedown
|
|
75396
|
+
* point, during mid-drag radius changes.
|
|
75397
|
+
*/
|
|
75398
|
+
this.lastDisplayX = 0;
|
|
75399
|
+
this.lastDisplayY = 0;
|
|
75400
|
+
this.lastSliceIndex = 0;
|
|
75401
|
+
/**
|
|
75402
|
+
* All sphere centers written during the current stroke. The wheel handler
|
|
75403
|
+
* retroactively re-renders the entire stroke at the new radius by restoring
|
|
75404
|
+
* `dragBeforeSnapshots` and replaying every entry here.
|
|
75405
|
+
*/
|
|
75406
|
+
this.strokeCenters = [];
|
|
74983
75407
|
this.callbacks = callbacks;
|
|
74984
75408
|
}
|
|
74985
75409
|
setCallbacks(callbacks) {
|
|
@@ -74987,41 +75411,66 @@ void main() {
|
|
|
74987
75411
|
}
|
|
74988
75412
|
// ── Geometry (ported from SphereTool) ──────────────────────────
|
|
74989
75413
|
/**
|
|
74990
|
-
* Convert
|
|
75414
|
+
* Convert display (zoomed) coordinates to 3D voxel coordinates.
|
|
75415
|
+
*
|
|
75416
|
+
* Uses `changedWidth/Height` as the display scale directly (instead of
|
|
75417
|
+
* going through mm via `/sizeFactor`). This keeps the write pixel-exact
|
|
75418
|
+
* with the preview circle, which is drawn on a `changedWidth x changedHeight`
|
|
75419
|
+
* sized sphere canvas — avoiding floating-point drift at high zoom levels.
|
|
74991
75420
|
*/
|
|
74992
|
-
canvasToVoxelCenter(
|
|
75421
|
+
canvasToVoxelCenter(displayX, displayY, sliceIndex, axis) {
|
|
74993
75422
|
const nrrd = this.ctx.nrrd_states;
|
|
75423
|
+
const cw = nrrd.view.changedWidth;
|
|
75424
|
+
const ch = nrrd.view.changedHeight;
|
|
74994
75425
|
switch (axis) {
|
|
74995
75426
|
case 'z':
|
|
74996
75427
|
return {
|
|
74997
|
-
x:
|
|
74998
|
-
y:
|
|
75428
|
+
x: displayX * nrrd.image.nrrd_x_pixel / cw,
|
|
75429
|
+
y: displayY * nrrd.image.nrrd_y_pixel / ch,
|
|
74999
75430
|
z: sliceIndex,
|
|
75000
75431
|
};
|
|
75001
75432
|
case 'y':
|
|
75002
75433
|
return {
|
|
75003
|
-
x:
|
|
75434
|
+
x: displayX * nrrd.image.nrrd_x_pixel / cw,
|
|
75004
75435
|
y: sliceIndex,
|
|
75005
|
-
|
|
75436
|
+
// Coronal vertical flip: display Y=0 is top, voxel Z increases downward in mm-space
|
|
75437
|
+
z: (ch - displayY) * nrrd.image.nrrd_z_pixel / ch,
|
|
75006
75438
|
};
|
|
75007
75439
|
case 'x':
|
|
75008
75440
|
return {
|
|
75009
75441
|
x: sliceIndex,
|
|
75010
|
-
y:
|
|
75011
|
-
z:
|
|
75442
|
+
y: displayY * nrrd.image.nrrd_y_pixel / ch,
|
|
75443
|
+
z: displayX * nrrd.image.nrrd_z_pixel / cw,
|
|
75012
75444
|
};
|
|
75013
75445
|
}
|
|
75014
75446
|
}
|
|
75015
75447
|
/**
|
|
75016
75448
|
* Convert mm radius to per-axis voxel radii.
|
|
75449
|
+
*
|
|
75450
|
+
* For the two axes visible in the current view, scale via
|
|
75451
|
+
* `changedWidth/Height` so the rendered ellipsoid cross-section matches
|
|
75452
|
+
* the preview pixel radius exactly. The third (slice-direction) axis is
|
|
75453
|
+
* not visible, so it uses the plain mm-based ratio.
|
|
75017
75454
|
*/
|
|
75018
|
-
getVoxelRadii(radius) {
|
|
75455
|
+
getVoxelRadii(radius, axis) {
|
|
75019
75456
|
const nrrd = this.ctx.nrrd_states;
|
|
75020
|
-
|
|
75021
|
-
|
|
75022
|
-
|
|
75023
|
-
|
|
75024
|
-
|
|
75457
|
+
const sf = nrrd.view.sizeFactor;
|
|
75458
|
+
const cw = nrrd.view.changedWidth;
|
|
75459
|
+
const ch = nrrd.view.changedHeight;
|
|
75460
|
+
// pixel-exact voxel radius for the visible horizontal/vertical dims
|
|
75461
|
+
const rxView = radius * sf * nrrd.image.nrrd_x_pixel / cw;
|
|
75462
|
+
const ryView = radius * sf * nrrd.image.nrrd_y_pixel / ch;
|
|
75463
|
+
const rzHoriz = radius * sf * nrrd.image.nrrd_z_pixel / cw;
|
|
75464
|
+
const rzVert = radius * sf * nrrd.image.nrrd_z_pixel / ch;
|
|
75465
|
+
// mm-based fallback for the slice-direction (perpendicular) dim
|
|
75466
|
+
const rxMm = radius * nrrd.image.nrrd_x_pixel / nrrd.image.nrrd_x_mm;
|
|
75467
|
+
const ryMm = radius * nrrd.image.nrrd_y_pixel / nrrd.image.nrrd_y_mm;
|
|
75468
|
+
const rzMm = radius * nrrd.image.nrrd_z_pixel / nrrd.image.nrrd_z_mm;
|
|
75469
|
+
switch (axis) {
|
|
75470
|
+
case 'z': return { rx: rxView, ry: ryView, rz: rzMm };
|
|
75471
|
+
case 'y': return { rx: rxView, ry: ryMm, rz: rzVert };
|
|
75472
|
+
case 'x': return { rx: rxMm, ry: ryView, rz: rzHoriz };
|
|
75473
|
+
}
|
|
75025
75474
|
}
|
|
75026
75475
|
/**
|
|
75027
75476
|
* Compute clamped bounding box for an ellipsoid in voxel space.
|
|
@@ -75126,11 +75575,15 @@ void main() {
|
|
|
75126
75575
|
drawPreview(mouseX, mouseY, radius, isEraser) {
|
|
75127
75576
|
const sphereCanvas = this.ctx.protectedData.canvases.drawingSphereCanvas;
|
|
75128
75577
|
const sphereCtx = this.ctx.protectedData.ctxes.drawingSphereCtx;
|
|
75129
|
-
//
|
|
75130
|
-
|
|
75131
|
-
|
|
75578
|
+
// Size sphere canvas to the zoomed display size so start() draws it 1:1 —
|
|
75579
|
+
// no scaling step, eliminating any zoom-dependent positioning error.
|
|
75580
|
+
const sf = this.ctx.nrrd_states.view.sizeFactor;
|
|
75581
|
+
sphereCanvas.width = this.ctx.nrrd_states.view.changedWidth;
|
|
75582
|
+
sphereCanvas.height = this.ctx.nrrd_states.view.changedHeight;
|
|
75583
|
+
// mouseX/mouseY are already in zoomed display coords; scale radius to match.
|
|
75584
|
+
const scaledRadius = radius * sf;
|
|
75132
75585
|
sphereCtx.beginPath();
|
|
75133
|
-
sphereCtx.arc(mouseX, mouseY,
|
|
75586
|
+
sphereCtx.arc(mouseX, mouseY, scaledRadius, 0, 2 * Math.PI);
|
|
75134
75587
|
if (isEraser) {
|
|
75135
75588
|
// Eraser preview: dashed outline
|
|
75136
75589
|
sphereCtx.strokeStyle = "#ff4444";
|
|
@@ -75183,38 +75636,135 @@ void main() {
|
|
|
75183
75636
|
return (e) => {
|
|
75184
75637
|
e.preventDefault();
|
|
75185
75638
|
const sphere = this.ctx.nrrd_states.sphere;
|
|
75186
|
-
|
|
75187
|
-
|
|
75188
|
-
|
|
75189
|
-
|
|
75190
|
-
|
|
75639
|
+
sphere.sphereBrushRadius = Math.max(1, Math.min(sphere.sphereBrushRadius + (e.deltaY < 0 ? 1 : -1), 50));
|
|
75640
|
+
// 3D Slicer-style retroactive resize: if the user has already started
|
|
75641
|
+
// painting/erasing a stroke, re-render the entire stroke at the new
|
|
75642
|
+
// radius so the whole pipe/erase-path resizes together.
|
|
75643
|
+
if (this.active && this.dragMoved && this.strokeCenters.length > 0) {
|
|
75644
|
+
this.replayStrokeWithCurrentRadius();
|
|
75191
75645
|
}
|
|
75192
|
-
|
|
75193
|
-
//
|
|
75646
|
+
// Preview always follows the latest cursor position, not the mousedown
|
|
75647
|
+
// point — otherwise the preview circle appears stuck on click-down when
|
|
75648
|
+
// the user scrolls after dragging elsewhere.
|
|
75194
75649
|
if (this.active) {
|
|
75195
|
-
this.drawPreview(this.
|
|
75650
|
+
this.drawPreview(this.lastDisplayX, this.lastDisplayY, sphere.sphereBrushRadius, this.mode === "eraser");
|
|
75196
75651
|
}
|
|
75197
75652
|
};
|
|
75198
75653
|
}
|
|
75654
|
+
/**
|
|
75655
|
+
* Retroactively re-render the current stroke at the current radius.
|
|
75656
|
+
*
|
|
75657
|
+
* Step 1: walk every recorded stroke center and expand
|
|
75658
|
+
* `dragBeforeSnapshots` to cover the new (larger) bounding box. Slices
|
|
75659
|
+
* newly included here are still pristine because the original stroke
|
|
75660
|
+
* never touched them.
|
|
75661
|
+
* Step 2: restore every snapshotted slice from its pristine backup.
|
|
75662
|
+
* Step 3: replay every stroke center at the new radius.
|
|
75663
|
+
*
|
|
75664
|
+
* Step 3 produces the same final state as if the user had used the new
|
|
75665
|
+
* radius from the start. Undo still captures the full stroke as one group
|
|
75666
|
+
* at pointerup.
|
|
75667
|
+
*/
|
|
75668
|
+
replayStrokeWithCurrentRadius() {
|
|
75669
|
+
if (this.strokeCenters.length === 0)
|
|
75670
|
+
return;
|
|
75671
|
+
const layer = this.ctx.gui_states.layerChannel.layer;
|
|
75672
|
+
const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
|
|
75673
|
+
const axis = this.ctx.protectedData.axis;
|
|
75674
|
+
const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
|
|
75675
|
+
let vol;
|
|
75676
|
+
try {
|
|
75677
|
+
vol = this.callbacks.getVolumeForLayer(layer);
|
|
75678
|
+
}
|
|
75679
|
+
catch (_a) {
|
|
75680
|
+
return;
|
|
75681
|
+
}
|
|
75682
|
+
const dims = vol.getDimensions();
|
|
75683
|
+
// 1. Expand snapshot coverage using the new radius so any newly-exposed
|
|
75684
|
+
// Z slices are captured while still pristine.
|
|
75685
|
+
for (const sc of this.strokeCenters) {
|
|
75686
|
+
const center = this.canvasToVoxelCenter(sc.x, sc.y, sc.sliceIndex, axis);
|
|
75687
|
+
const radii = this.getVoxelRadii(radius, axis);
|
|
75688
|
+
const bb = this.getBoundingBox(center, radii, dims);
|
|
75689
|
+
this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
|
|
75690
|
+
}
|
|
75691
|
+
// 2. Restore every snapshotted slice from pristine backup.
|
|
75692
|
+
for (const [z, pristine] of this.dragBeforeSnapshots) {
|
|
75693
|
+
try {
|
|
75694
|
+
vol.setSliceUint8(z, pristine, 'z');
|
|
75695
|
+
}
|
|
75696
|
+
catch (_b) {
|
|
75697
|
+
// Slice write failed — skip silently so a single bad slice doesn't break the replay.
|
|
75698
|
+
}
|
|
75699
|
+
}
|
|
75700
|
+
// 3. Replay every stroke center with the new radius.
|
|
75701
|
+
for (const sc of this.strokeCenters) {
|
|
75702
|
+
const center = this.canvasToVoxelCenter(sc.x, sc.y, sc.sliceIndex, axis);
|
|
75703
|
+
const radii = this.getVoxelRadii(radius, axis);
|
|
75704
|
+
const bb = this.getBoundingBox(center, radii, dims);
|
|
75705
|
+
if (this.mode === "brush") {
|
|
75706
|
+
this.write3DSphereToBrush(vol, center, radii, bb, channel);
|
|
75707
|
+
}
|
|
75708
|
+
else {
|
|
75709
|
+
this.erase3DSphereFromVolume(vol, center, radii, bb, channel);
|
|
75710
|
+
}
|
|
75711
|
+
}
|
|
75712
|
+
this.refreshDisplay(layer, undefined, false);
|
|
75713
|
+
}
|
|
75199
75714
|
// ── Sphere Brush Click/PointerUp ───────────────────────────────
|
|
75200
75715
|
/**
|
|
75201
|
-
* Handle pointer-down in sphereBrush mode (
|
|
75716
|
+
* Handle pointer-down in sphereBrush mode (3D Slicer-style).
|
|
75717
|
+
*
|
|
75718
|
+
* Captures pristine snapshots at the mousedown location but does NOT
|
|
75719
|
+
* write any voxels yet — the user may wheel-adjust the radius before
|
|
75720
|
+
* dragging. The first sphere is written either by the first pointermove
|
|
75721
|
+
* or by pointerup (single-click fallback).
|
|
75202
75722
|
*/
|
|
75203
75723
|
onSphereBrushClick(e) {
|
|
75204
|
-
this.centerX = e.offsetX
|
|
75205
|
-
this.centerY = e.offsetY
|
|
75724
|
+
this.centerX = e.offsetX;
|
|
75725
|
+
this.centerY = e.offsetY;
|
|
75206
75726
|
this.centerSlice = this.ctx.nrrd_states.view.currentSliceIndex;
|
|
75727
|
+
this.lastDisplayX = this.centerX;
|
|
75728
|
+
this.lastDisplayY = this.centerY;
|
|
75729
|
+
this.lastSliceIndex = this.centerSlice;
|
|
75207
75730
|
this.active = true;
|
|
75208
75731
|
this.mode = "brush";
|
|
75209
|
-
this.
|
|
75732
|
+
this.dragMoved = false;
|
|
75733
|
+
this.dragBeforeSnapshots.clear();
|
|
75734
|
+
this.strokeCenters = [{ x: this.centerX, y: this.centerY, sliceIndex: this.centerSlice }];
|
|
75735
|
+
const layer = this.ctx.gui_states.layerChannel.layer;
|
|
75736
|
+
const axis = this.ctx.protectedData.axis;
|
|
75737
|
+
const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
|
|
75738
|
+
try {
|
|
75739
|
+
const vol = this.callbacks.getVolumeForLayer(layer);
|
|
75740
|
+
const dims = vol.getDimensions();
|
|
75741
|
+
const center = this.canvasToVoxelCenter(this.centerX, this.centerY, this.centerSlice, axis);
|
|
75742
|
+
const radii = this.getVoxelRadii(radius, axis);
|
|
75743
|
+
const bb = this.getBoundingBox(center, radii, dims);
|
|
75744
|
+
this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
|
|
75745
|
+
}
|
|
75746
|
+
catch (_a) {
|
|
75747
|
+
// Volume not ready — preview still shown so user gets visual feedback
|
|
75748
|
+
}
|
|
75749
|
+
this.drawPreview(this.centerX, this.centerY, radius, false);
|
|
75210
75750
|
}
|
|
75211
75751
|
/**
|
|
75212
|
-
* Handle pointer-
|
|
75752
|
+
* Handle pointer-move in sphereBrush mode — drag to continuously paint.
|
|
75753
|
+
*
|
|
75754
|
+
* Mirrors sphereEraser drag: writes a new sphere at each move position,
|
|
75755
|
+
* extends the cumulative before-snapshot to cover any newly-touched Z
|
|
75756
|
+
* slices, and repaints the preview. Never notifies the backend; pointerup
|
|
75757
|
+
* does that once for the whole stroke.
|
|
75213
75758
|
*/
|
|
75214
|
-
|
|
75759
|
+
onSphereBrushMove(e) {
|
|
75215
75760
|
if (!this.active || this.mode !== "brush")
|
|
75216
75761
|
return;
|
|
75217
|
-
this.
|
|
75762
|
+
this.dragMoved = true;
|
|
75763
|
+
const sliceIndex = this.ctx.nrrd_states.view.currentSliceIndex;
|
|
75764
|
+
this.lastDisplayX = e.offsetX;
|
|
75765
|
+
this.lastDisplayY = e.offsetY;
|
|
75766
|
+
this.lastSliceIndex = sliceIndex;
|
|
75767
|
+
this.strokeCenters.push({ x: e.offsetX, y: e.offsetY, sliceIndex });
|
|
75218
75768
|
const layer = this.ctx.gui_states.layerChannel.layer;
|
|
75219
75769
|
const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
|
|
75220
75770
|
const axis = this.ctx.protectedData.axis;
|
|
@@ -75224,26 +75774,56 @@ void main() {
|
|
|
75224
75774
|
vol = this.callbacks.getVolumeForLayer(layer);
|
|
75225
75775
|
}
|
|
75226
75776
|
catch (_a) {
|
|
75227
|
-
this.clearPreview();
|
|
75228
75777
|
return;
|
|
75229
75778
|
}
|
|
75230
75779
|
const dims = vol.getDimensions();
|
|
75231
|
-
const center = this.canvasToVoxelCenter(
|
|
75232
|
-
const radii = this.getVoxelRadii(radius);
|
|
75780
|
+
const center = this.canvasToVoxelCenter(e.offsetX, e.offsetY, sliceIndex, axis);
|
|
75781
|
+
const radii = this.getVoxelRadii(radius, axis);
|
|
75233
75782
|
const bb = this.getBoundingBox(center, radii, dims);
|
|
75234
|
-
|
|
75235
|
-
const before = this.captureSliceSnapshots(vol, bb.minZ, bb.maxZ);
|
|
75236
|
-
// Write sphere
|
|
75783
|
+
this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
|
|
75237
75784
|
this.write3DSphereToBrush(vol, center, radii, bb, channel);
|
|
75238
|
-
|
|
75239
|
-
|
|
75240
|
-
|
|
75785
|
+
this.drawPreview(e.offsetX, e.offsetY, radius, false);
|
|
75786
|
+
this.refreshDisplay(layer, undefined, false);
|
|
75787
|
+
}
|
|
75788
|
+
/**
|
|
75789
|
+
* Handle pointer-up in sphereBrush mode — finalize stroke + push undo group
|
|
75790
|
+
* + single batched backend notify for all Z slices touched during the drag.
|
|
75791
|
+
*/
|
|
75792
|
+
onSphereBrushPointerUp() {
|
|
75793
|
+
if (!this.active || this.mode !== "brush")
|
|
75794
|
+
return;
|
|
75795
|
+
this.active = false;
|
|
75796
|
+
const layer = this.ctx.gui_states.layerChannel.layer;
|
|
75797
|
+
const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
|
|
75798
|
+
const axis = this.ctx.protectedData.axis;
|
|
75799
|
+
const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
|
|
75800
|
+
let vol;
|
|
75801
|
+
try {
|
|
75802
|
+
vol = this.callbacks.getVolumeForLayer(layer);
|
|
75803
|
+
}
|
|
75804
|
+
catch (_a) {
|
|
75805
|
+
this.finalizeStrokeState();
|
|
75806
|
+
return;
|
|
75807
|
+
}
|
|
75808
|
+
// Click-without-drag: write the single sphere now so a plain click still
|
|
75809
|
+
// paints something. onSphereBrushClick intentionally skipped this so the
|
|
75810
|
+
// user could wheel-adjust radius first.
|
|
75811
|
+
if (!this.dragMoved && this.strokeCenters.length > 0) {
|
|
75812
|
+
const sc = this.strokeCenters[0];
|
|
75813
|
+
const dims = vol.getDimensions();
|
|
75814
|
+
const center = this.canvasToVoxelCenter(sc.x, sc.y, sc.sliceIndex, axis);
|
|
75815
|
+
const radii = this.getVoxelRadii(radius, axis);
|
|
75816
|
+
const bb = this.getBoundingBox(center, radii, dims);
|
|
75817
|
+
this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
|
|
75818
|
+
this.write3DSphereToBrush(vol, center, radii, bb, channel);
|
|
75819
|
+
}
|
|
75820
|
+
const after = this.captureSliceSnapshots(vol, this.dragMinZ(), this.dragMaxZ());
|
|
75821
|
+
const deltas = this.buildUndoGroup(layer, this.dragBeforeSnapshots, after);
|
|
75241
75822
|
if (deltas.length > 0) {
|
|
75242
75823
|
this.callbacks.pushUndoGroup(deltas);
|
|
75243
75824
|
}
|
|
75244
|
-
|
|
75245
|
-
this.
|
|
75246
|
-
this.clearPreview();
|
|
75825
|
+
this.refreshDisplay(layer, deltas, true);
|
|
75826
|
+
this.finalizeStrokeState();
|
|
75247
75827
|
}
|
|
75248
75828
|
// ── Sphere Eraser Click/PointerUp ──────────────────────────────
|
|
75249
75829
|
/**
|
|
@@ -75251,13 +75831,17 @@ void main() {
|
|
|
75251
75831
|
* Initializes drag tracking for cumulative undo.
|
|
75252
75832
|
*/
|
|
75253
75833
|
onSphereEraserClick(e) {
|
|
75254
|
-
this.centerX = e.offsetX
|
|
75255
|
-
this.centerY = e.offsetY
|
|
75834
|
+
this.centerX = e.offsetX;
|
|
75835
|
+
this.centerY = e.offsetY;
|
|
75256
75836
|
this.centerSlice = this.ctx.nrrd_states.view.currentSliceIndex;
|
|
75837
|
+
this.lastDisplayX = this.centerX;
|
|
75838
|
+
this.lastDisplayY = this.centerY;
|
|
75839
|
+
this.lastSliceIndex = this.centerSlice;
|
|
75257
75840
|
this.active = true;
|
|
75258
75841
|
this.mode = "eraser";
|
|
75259
75842
|
this.dragMoved = false;
|
|
75260
75843
|
this.dragBeforeSnapshots.clear();
|
|
75844
|
+
this.strokeCenters = [{ x: this.centerX, y: this.centerY, sliceIndex: this.centerSlice }];
|
|
75261
75845
|
// Capture initial "before" snapshots for the first sphere position
|
|
75262
75846
|
const layer = this.ctx.gui_states.layerChannel.layer;
|
|
75263
75847
|
const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
|
|
@@ -75266,7 +75850,7 @@ void main() {
|
|
|
75266
75850
|
const dims = vol.getDimensions();
|
|
75267
75851
|
const axis = this.ctx.protectedData.axis;
|
|
75268
75852
|
const center = this.canvasToVoxelCenter(this.centerX, this.centerY, this.centerSlice, axis);
|
|
75269
|
-
const radii = this.getVoxelRadii(radius);
|
|
75853
|
+
const radii = this.getVoxelRadii(radius, axis);
|
|
75270
75854
|
const bb = this.getBoundingBox(center, radii, dims);
|
|
75271
75855
|
this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
|
|
75272
75856
|
}
|
|
@@ -75282,9 +75866,11 @@ void main() {
|
|
|
75282
75866
|
if (!this.active || this.mode !== "eraser")
|
|
75283
75867
|
return;
|
|
75284
75868
|
this.dragMoved = true;
|
|
75285
|
-
const mouseX = e.offsetX / this.ctx.nrrd_states.view.sizeFactor;
|
|
75286
|
-
const mouseY = e.offsetY / this.ctx.nrrd_states.view.sizeFactor;
|
|
75287
75869
|
const sliceIndex = this.ctx.nrrd_states.view.currentSliceIndex;
|
|
75870
|
+
this.lastDisplayX = e.offsetX;
|
|
75871
|
+
this.lastDisplayY = e.offsetY;
|
|
75872
|
+
this.lastSliceIndex = sliceIndex;
|
|
75873
|
+
this.strokeCenters.push({ x: e.offsetX, y: e.offsetY, sliceIndex });
|
|
75288
75874
|
const layer = this.ctx.gui_states.layerChannel.layer;
|
|
75289
75875
|
const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
|
|
75290
75876
|
const axis = this.ctx.protectedData.axis;
|
|
@@ -75297,17 +75883,18 @@ void main() {
|
|
|
75297
75883
|
return;
|
|
75298
75884
|
}
|
|
75299
75885
|
const dims = vol.getDimensions();
|
|
75300
|
-
const center = this.canvasToVoxelCenter(
|
|
75301
|
-
const radii = this.getVoxelRadii(radius);
|
|
75886
|
+
const center = this.canvasToVoxelCenter(e.offsetX, e.offsetY, sliceIndex, axis);
|
|
75887
|
+
const radii = this.getVoxelRadii(radius, axis);
|
|
75302
75888
|
const bb = this.getBoundingBox(center, radii, dims);
|
|
75303
75889
|
// Expand "before" snapshots to cover any new Z slices
|
|
75304
75890
|
this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
|
|
75305
75891
|
// Erase sphere at current position
|
|
75306
75892
|
this.erase3DSphereFromVolume(vol, center, radii, bb, channel);
|
|
75307
|
-
// Update preview position
|
|
75308
|
-
this.drawPreview(
|
|
75309
|
-
// Refresh display
|
|
75310
|
-
|
|
75893
|
+
// Update preview position (pass display/zoomed coords)
|
|
75894
|
+
this.drawPreview(e.offsetX, e.offsetY, radius, true);
|
|
75895
|
+
// Refresh display locally — skip backend notify to avoid per-frame HTTP.
|
|
75896
|
+
// onSphereEraserPointerUp batches all deltas into a single notify.
|
|
75897
|
+
this.refreshDisplay(layer, undefined, false);
|
|
75311
75898
|
}
|
|
75312
75899
|
/**
|
|
75313
75900
|
* Handle pointer-up in sphereEraser mode — finalize erase + push undo group.
|
|
@@ -75326,16 +75913,17 @@ void main() {
|
|
|
75326
75913
|
vol = this.callbacks.getVolumeForLayer(layer);
|
|
75327
75914
|
}
|
|
75328
75915
|
catch (_a) {
|
|
75329
|
-
this.
|
|
75330
|
-
this.dragBeforeSnapshots.clear();
|
|
75916
|
+
this.finalizeStrokeState();
|
|
75331
75917
|
return;
|
|
75332
75918
|
}
|
|
75333
|
-
//
|
|
75334
|
-
if (!this.dragMoved) {
|
|
75919
|
+
// Click-without-drag: erase at the click position (click-release behavior).
|
|
75920
|
+
if (!this.dragMoved && this.strokeCenters.length > 0) {
|
|
75921
|
+
const sc = this.strokeCenters[0];
|
|
75335
75922
|
const dims = vol.getDimensions();
|
|
75336
|
-
const center = this.canvasToVoxelCenter(
|
|
75337
|
-
const radii = this.getVoxelRadii(radius);
|
|
75923
|
+
const center = this.canvasToVoxelCenter(sc.x, sc.y, sc.sliceIndex, axis);
|
|
75924
|
+
const radii = this.getVoxelRadii(radius, axis);
|
|
75338
75925
|
const bb = this.getBoundingBox(center, radii, dims);
|
|
75926
|
+
this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
|
|
75339
75927
|
this.erase3DSphereFromVolume(vol, center, radii, bb, channel);
|
|
75340
75928
|
}
|
|
75341
75929
|
// Build undo group from cumulative "before" snapshots vs current state
|
|
@@ -75344,10 +75932,20 @@ void main() {
|
|
|
75344
75932
|
if (deltas.length > 0) {
|
|
75345
75933
|
this.callbacks.pushUndoGroup(deltas);
|
|
75346
75934
|
}
|
|
75347
|
-
// Refresh display and notify backend of ALL changed slices
|
|
75348
|
-
this.refreshDisplay(layer, deltas);
|
|
75935
|
+
// Refresh display and notify backend of ALL changed slices — single HTTP batch
|
|
75936
|
+
this.refreshDisplay(layer, deltas, true);
|
|
75937
|
+
this.finalizeStrokeState();
|
|
75938
|
+
}
|
|
75939
|
+
/**
|
|
75940
|
+
* Clear per-stroke state after pointerup or cancel. Kept as a helper so
|
|
75941
|
+
* brush/eraser pointerup and cancelActivePlacement all reset the same set
|
|
75942
|
+
* of fields — if a new drag-state field is added, only one place to update.
|
|
75943
|
+
*/
|
|
75944
|
+
finalizeStrokeState() {
|
|
75349
75945
|
this.clearPreview();
|
|
75350
75946
|
this.dragBeforeSnapshots.clear();
|
|
75947
|
+
this.strokeCenters = [];
|
|
75948
|
+
this.dragMoved = false;
|
|
75351
75949
|
}
|
|
75352
75950
|
/**
|
|
75353
75951
|
* Expand cumulative "before" snapshots to cover z range [minZ, maxZ].
|
|
@@ -75392,8 +75990,11 @@ void main() {
|
|
|
75392
75990
|
* @param changedDeltas - If provided, fire onMaskChanged for ALL changed
|
|
75393
75991
|
* slices (not just the current view slice) so the backend receives the
|
|
75394
75992
|
* full 3D sphere data for NII/GLTF export.
|
|
75993
|
+
* @param notifyBackend - When false, only refresh the local canvas and skip
|
|
75994
|
+
* the onMaskChanged callback. Used during drag to avoid per-frame HTTP;
|
|
75995
|
+
* pointerup then calls with `true` to send a single batched update.
|
|
75395
75996
|
*/
|
|
75396
|
-
refreshDisplay(layerId, changedDeltas) {
|
|
75997
|
+
refreshDisplay(layerId, changedDeltas, notifyBackend = true) {
|
|
75397
75998
|
// Re-render layer canvas from volume for the current slice
|
|
75398
75999
|
const target = this.ctx.protectedData.layerTargets.get(layerId);
|
|
75399
76000
|
if (target) {
|
|
@@ -75406,6 +76007,9 @@ void main() {
|
|
|
75406
76007
|
}
|
|
75407
76008
|
// Composite all layers to master canvas
|
|
75408
76009
|
this.callbacks.compositeAllLayers();
|
|
76010
|
+
// Skip backend notify during drag — pointerup batches a single notify for the whole stroke.
|
|
76011
|
+
if (!notifyBackend)
|
|
76012
|
+
return;
|
|
75409
76013
|
// Fire onMaskChanged for ALL changed slices (not just the current view slice)
|
|
75410
76014
|
// This ensures the backend receives the full 3D sphere data for export.
|
|
75411
76015
|
try {
|
|
@@ -75434,6 +76038,23 @@ void main() {
|
|
|
75434
76038
|
get isActive() {
|
|
75435
76039
|
return this.active;
|
|
75436
76040
|
}
|
|
76041
|
+
/**
|
|
76042
|
+
* Defensively cancel any in-progress sphere placement and clear the preview.
|
|
76043
|
+
*
|
|
76044
|
+
* Called when crosshair mode is entered so any lingering sphere preview
|
|
76045
|
+
* (e.g. red dashed eraser outline) is wiped immediately. In normal flow
|
|
76046
|
+
* `leftButtonDown === true` blocks crosshair toggle, so this shouldn't
|
|
76047
|
+
* fire mid-stroke — but if it ever does, we drop the drag snapshot
|
|
76048
|
+
* rather than committing a partial undo entry.
|
|
76049
|
+
*/
|
|
76050
|
+
cancelActivePlacement() {
|
|
76051
|
+
if (!this.active) {
|
|
76052
|
+
this.clearPreview();
|
|
76053
|
+
return;
|
|
76054
|
+
}
|
|
76055
|
+
this.active = false;
|
|
76056
|
+
this.finalizeStrokeState();
|
|
76057
|
+
}
|
|
75437
76058
|
}
|
|
75438
76059
|
|
|
75439
76060
|
/**
|
|
@@ -75538,6 +76159,8 @@ void main() {
|
|
|
75538
76159
|
getVolumeForLayer: (layer) => this.renderer.getVolumeForLayer(layer),
|
|
75539
76160
|
pushUndoDelta: (delta) => this.undoManager.push(delta),
|
|
75540
76161
|
getEraserUrls: () => this.eraserUrls,
|
|
76162
|
+
renderSliceToCanvas: (layer, axis, sliceIndex, buffer, ctx, w, h) => this.renderer.renderSliceToCanvas(layer, axis, sliceIndex, buffer, ctx, w, h),
|
|
76163
|
+
getOrCreateSliceBuffer: (axis) => this.renderer.getOrCreateSliceBuffer(axis),
|
|
75541
76164
|
});
|
|
75542
76165
|
this.sphereBrushTool = new SphereBrushTool(toolCtx, {
|
|
75543
76166
|
getVolumeForLayer: (layer) => this.renderer.getVolumeForLayer(layer),
|
|
@@ -75556,6 +76179,7 @@ void main() {
|
|
|
75556
76179
|
container: this.container,
|
|
75557
76180
|
canvas: this.state.protectedData.canvases.drawingCanvas,
|
|
75558
76181
|
onModeChange: (prevMode, newMode) => {
|
|
76182
|
+
var _a;
|
|
75559
76183
|
// Use string comparison to avoid TypeScript narrowing issues
|
|
75560
76184
|
const prev = prevMode;
|
|
75561
76185
|
const next = newMode;
|
|
@@ -75568,6 +76192,10 @@ void main() {
|
|
|
75568
76192
|
}
|
|
75569
76193
|
if (next === 'crosshair') {
|
|
75570
76194
|
this.state.protectedData.isDrawing = false;
|
|
76195
|
+
// Wipe any lingering sphere preview (red dashed eraser outline /
|
|
76196
|
+
// filled brush preview) when the user toggles into crosshair from
|
|
76197
|
+
// sphereBrush/sphereEraser. Safe no-op when no placement is active.
|
|
76198
|
+
(_a = this.sphereBrushTool) === null || _a === void 0 ? void 0 : _a.cancelActivePlacement();
|
|
75571
76199
|
}
|
|
75572
76200
|
}
|
|
75573
76201
|
});
|
|
@@ -75666,9 +76294,14 @@ void main() {
|
|
|
75666
76294
|
if (this.drawingTool.isActive || this.panTool.isActive) {
|
|
75667
76295
|
this.drawingPrameters.handleOnDrawingMouseMove(e);
|
|
75668
76296
|
}
|
|
75669
|
-
// Drag
|
|
75670
|
-
if (this.sphereBrushTool.isActive
|
|
75671
|
-
this.
|
|
76297
|
+
// Drag: route move to sphereBrushTool for both brush and eraser
|
|
76298
|
+
if (this.sphereBrushTool.isActive) {
|
|
76299
|
+
if (this.state.gui_states.mode.sphereEraser) {
|
|
76300
|
+
this.sphereBrushTool.onSphereEraserMove(e);
|
|
76301
|
+
}
|
|
76302
|
+
else if (this.state.gui_states.mode.sphereBrush) {
|
|
76303
|
+
this.sphereBrushTool.onSphereBrushMove(e);
|
|
76304
|
+
}
|
|
75672
76305
|
}
|
|
75673
76306
|
});
|
|
75674
76307
|
this.eventRouter.setPointerUpHandler((e) => {
|
|
@@ -75967,6 +76600,11 @@ void main() {
|
|
|
75967
76600
|
const ctx = this.state.protectedData.ctxes.emptyCtx;
|
|
75968
76601
|
const w = this.state.protectedData.canvases.emptyCanvas.width;
|
|
75969
76602
|
const h = this.state.protectedData.canvases.emptyCanvas.height;
|
|
76603
|
+
// Bake step: downscale layer canvas (display resolution) to emptyCanvas
|
|
76604
|
+
// (voxel resolution) before label extraction. Use nearest-neighbor
|
|
76605
|
+
// (smoothing=false) so voxel occupancy is determined directly by the
|
|
76606
|
+
// drawn pixels on the layer canvas, without blending that could shift
|
|
76607
|
+
// the alpha>=128 threshold decision in setSliceLabelsFromImageData.
|
|
75970
76608
|
ctx.imageSmoothingEnabled = false;
|
|
75971
76609
|
ctx.drawImage(canvas, 0, 0, w, h);
|
|
75972
76610
|
}
|
|
@@ -77673,11 +78311,15 @@ void main() {
|
|
|
77673
78311
|
this.state.gui_states.mode.sphereBrush = true;
|
|
77674
78312
|
this.dragOperator.removeDragMode();
|
|
77675
78313
|
(_a = this.drawCore.eventRouter) === null || _a === void 0 ? void 0 : _a.setGuiTool('sphereBrush');
|
|
78314
|
+
this.state.protectedData.canvases.drawingCanvas.style.cursor =
|
|
78315
|
+
this.state.gui_states.viewConfig.defaultPaintCursor;
|
|
77676
78316
|
break;
|
|
77677
78317
|
case "sphereEraser":
|
|
77678
78318
|
this.state.gui_states.mode.sphereEraser = true;
|
|
77679
78319
|
this.dragOperator.removeDragMode();
|
|
77680
78320
|
(_b = this.drawCore.eventRouter) === null || _b === void 0 ? void 0 : _b.setGuiTool('sphereEraser');
|
|
78321
|
+
this.state.protectedData.canvases.drawingCanvas.style.cursor =
|
|
78322
|
+
this.state.gui_states.viewConfig.defaultPaintCursor;
|
|
77681
78323
|
break;
|
|
77682
78324
|
}
|
|
77683
78325
|
// Restore drag mode when leaving sphereBrush/sphereEraser
|
|
@@ -78405,6 +79047,42 @@ void main() {
|
|
|
78405
79047
|
drawCalculatorSphereOnEachViews(axis) {
|
|
78406
79048
|
this.drawCore.drawCalculatorSphereOnEachViews(axis);
|
|
78407
79049
|
}
|
|
79050
|
+
/**
|
|
79051
|
+
* Copy all voxel data from one layer's MaskVolume to another.
|
|
79052
|
+
*
|
|
79053
|
+
* After the copy the target layer's display is refreshed so the UI
|
|
79054
|
+
* shows the updated data immediately. The target layer's color map
|
|
79055
|
+
* is preserved — only the raw voxel buffer is overwritten.
|
|
79056
|
+
*
|
|
79057
|
+
* Use case: when editing layer2 cascades to layer3 on the backend,
|
|
79058
|
+
* call this to keep the frontend in sync without a network round-trip.
|
|
79059
|
+
*
|
|
79060
|
+
* @param sourceLayerId Layer to copy from (e.g. "layer2").
|
|
79061
|
+
* @param targetLayerId Layer to copy into (e.g. "layer3").
|
|
79062
|
+
*
|
|
79063
|
+
* @example
|
|
79064
|
+
* ```ts
|
|
79065
|
+
* nrrdTools.copyLayerData("layer2", "layer3");
|
|
79066
|
+
* ```
|
|
79067
|
+
*/
|
|
79068
|
+
copyLayerData(sourceLayerId, targetLayerId) {
|
|
79069
|
+
const srcVol = this.drawCore.renderer.getVolumeForLayer(sourceLayerId);
|
|
79070
|
+
const dstVol = this.drawCore.renderer.getVolumeForLayer(targetLayerId);
|
|
79071
|
+
if (!srcVol || !dstVol) {
|
|
79072
|
+
console.warn(`copyLayerData: cannot resolve volumes for "${sourceLayerId}" → "${targetLayerId}"`);
|
|
79073
|
+
return;
|
|
79074
|
+
}
|
|
79075
|
+
const srcData = srcVol.getRawData();
|
|
79076
|
+
const dstData = dstVol.getRawData();
|
|
79077
|
+
if (srcData.length !== dstData.length) {
|
|
79078
|
+
console.warn(`copyLayerData: size mismatch (${srcData.length} vs ${dstData.length})`);
|
|
79079
|
+
return;
|
|
79080
|
+
}
|
|
79081
|
+
// Copy voxel data (preserves target color map)
|
|
79082
|
+
dstVol.setRawData(new Uint8Array(srcData));
|
|
79083
|
+
// Refresh the display so all three axes reflect the new data
|
|
79084
|
+
this.reloadMasksFromVolume();
|
|
79085
|
+
}
|
|
78408
79086
|
}
|
|
78409
79087
|
|
|
78410
79088
|
class Node {
|
|
@@ -78614,7 +79292,7 @@ void main() {
|
|
|
78614
79292
|
}
|
|
78615
79293
|
|
|
78616
79294
|
// import * as kiwrious from "copper3d_plugin_heart_k";
|
|
78617
|
-
const REVISION = "v3.
|
|
79295
|
+
const REVISION = "v3.4.0-beta";
|
|
78618
79296
|
console.log(`%cCopper3D Visualisation %cBeta:${REVISION}`, "padding: 3px;color:white; background:#023047", "padding: 3px;color:white; background:#f50a25");
|
|
78619
79297
|
|
|
78620
79298
|
exports.CHANNEL_COLORS = CHANNEL_COLORS;
|