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.esm.js
CHANGED
|
@@ -72064,6 +72064,175 @@ class UndoManager {
|
|
|
72064
72064
|
}
|
|
72065
72065
|
}
|
|
72066
72066
|
|
|
72067
|
+
/**
|
|
72068
|
+
* MarchingSquares — Extract vector contours from a 2D voxel label grid.
|
|
72069
|
+
*
|
|
72070
|
+
* For each label, produces a `Path2D` whose subpaths are small per-cell
|
|
72071
|
+
* polygons. The polygons tile the region occupied by the target label; the
|
|
72072
|
+
* union (via `ctx.fill(path, 'nonzero')`) is the label's silhouette on the
|
|
72073
|
+
* voxel grid.
|
|
72074
|
+
*
|
|
72075
|
+
* Coordinate convention:
|
|
72076
|
+
* - Voxel (i, j) occupies the unit square [i, i+1] × [j, j+1] in render
|
|
72077
|
+
* space, with its center at (i+0.5, j+0.5). This matches ITK-SNAP /
|
|
72078
|
+
* standard imaging viewers and the existing `renderSliceToCanvas`
|
|
72079
|
+
* `scale(scaledW / W, scaledH / H)` mapping.
|
|
72080
|
+
* - Marching squares treats voxels as point samples at their centers:
|
|
72081
|
+
* cell (i, j) has corners at voxel centers (i+0.5, j+0.5),
|
|
72082
|
+
* (i+1.5, j+0.5), (i+1.5, j+1.5), (i+0.5, j+1.5).
|
|
72083
|
+
* - This produces 45°-cut contours (diamonds for isolated voxels, rounded
|
|
72084
|
+
* corners for connected regions) that stay within the voxel-square
|
|
72085
|
+
* bounds, so the rendered silhouette is a slightly-inset version of the
|
|
72086
|
+
* ITK-SNAP silhouette with smooth diagonals.
|
|
72087
|
+
*
|
|
72088
|
+
* Saddle cases (5 and 10) use a fixed convention: the two in-corners are
|
|
72089
|
+
* treated as disconnected within the cell (4-connectivity interpretation).
|
|
72090
|
+
*/
|
|
72091
|
+
/**
|
|
72092
|
+
* Per-cell polygon lookup for the 16 marching-squares cases.
|
|
72093
|
+
*
|
|
72094
|
+
* Corner bit layout: TL=8, TR=4, BR=2, BL=1
|
|
72095
|
+
*
|
|
72096
|
+
* Coordinates are within the unit cell, 0 ≤ x ≤ 1, 0 ≤ y ≤ 1,
|
|
72097
|
+
* where (0, 0) is the TL corner of the cell. Add (i, j) to get absolute.
|
|
72098
|
+
*
|
|
72099
|
+
* Each case may emit 0, 1, or 2 polygons (saddles → 2).
|
|
72100
|
+
* Polygon vertices are listed in a CCW order in screen space (y-down),
|
|
72101
|
+
* which corresponds to CW in math coords. Path2D `fill(nonzero)` handles
|
|
72102
|
+
* this consistently.
|
|
72103
|
+
*/
|
|
72104
|
+
const CELL_POLYGONS = [
|
|
72105
|
+
/* 0 (empty) */ [],
|
|
72106
|
+
/* 1 (BL) */ [[[0, 0.5], [0.5, 1], [0, 1]]],
|
|
72107
|
+
/* 2 (BR) */ [[[0.5, 1], [1, 0.5], [1, 1]]],
|
|
72108
|
+
/* 3 (BR+BL) */ [[[0, 0.5], [1, 0.5], [1, 1], [0, 1]]],
|
|
72109
|
+
/* 4 (TR) */ [[[0.5, 0], [1, 0], [1, 0.5]]],
|
|
72110
|
+
/* 5 (TR+BL saddle) */ [
|
|
72111
|
+
[[0.5, 0], [1, 0], [1, 0.5]],
|
|
72112
|
+
[[0, 0.5], [0.5, 1], [0, 1]],
|
|
72113
|
+
],
|
|
72114
|
+
/* 6 (TR+BR) */ [[[0.5, 0], [1, 0], [1, 1], [0.5, 1]]],
|
|
72115
|
+
/* 7 (TR+BR+BL, !TL) */ [[[0.5, 0], [1, 0], [1, 1], [0, 1], [0, 0.5]]],
|
|
72116
|
+
/* 8 (TL) */ [[[0, 0], [0.5, 0], [0, 0.5]]],
|
|
72117
|
+
/* 9 (TL+BL) */ [[[0, 0], [0.5, 0], [0.5, 1], [0, 1]]],
|
|
72118
|
+
/* 10 (TL+BR saddle) */ [
|
|
72119
|
+
[[0, 0], [0.5, 0], [0, 0.5]],
|
|
72120
|
+
[[0.5, 1], [1, 0.5], [1, 1]],
|
|
72121
|
+
],
|
|
72122
|
+
/* 11 (TL+BR+BL, !TR) */ [[[0, 0], [0.5, 0], [1, 0.5], [1, 1], [0, 1]]],
|
|
72123
|
+
/* 12 (TL+TR) */ [[[0, 0], [1, 0], [1, 0.5], [0, 0.5]]],
|
|
72124
|
+
/* 13 (TL+TR+BL, !BR) */ [[[0, 0], [1, 0], [1, 0.5], [0.5, 1], [0, 1]]],
|
|
72125
|
+
/* 14 (TL+TR+BR, !BL) */ [[[0, 0], [1, 0], [1, 1], [0.5, 1], [0, 0.5]]],
|
|
72126
|
+
/* 15 (full) */ [[[0, 0], [1, 0], [1, 1], [0, 1]]],
|
|
72127
|
+
];
|
|
72128
|
+
/**
|
|
72129
|
+
* Emit all per-cell polygons covering the voxels where `labels === targetLabel`.
|
|
72130
|
+
* Pure geometry function — no Canvas dependency. Consumers can build a Path2D
|
|
72131
|
+
* (see {@link extractLabelContours}) or walk the vertex arrays directly
|
|
72132
|
+
* (tests, export, stroke rendering).
|
|
72133
|
+
*/
|
|
72134
|
+
function extractLabelPolygons(labels, width, height, targetLabel, stride = 1, channelOffset = 0, bbox) {
|
|
72135
|
+
var _a, _b, _c, _d;
|
|
72136
|
+
const out = [];
|
|
72137
|
+
const cx0 = ((_a = bbox === null || bbox === void 0 ? void 0 : bbox.x0) !== null && _a !== void 0 ? _a : 0) - 1;
|
|
72138
|
+
const cy0 = ((_b = bbox === null || bbox === void 0 ? void 0 : bbox.y0) !== null && _b !== void 0 ? _b : 0) - 1;
|
|
72139
|
+
const cx1 = (_c = bbox === null || bbox === void 0 ? void 0 : bbox.x1) !== null && _c !== void 0 ? _c : width;
|
|
72140
|
+
const cy1 = (_d = bbox === null || bbox === void 0 ? void 0 : bbox.y1) !== null && _d !== void 0 ? _d : height;
|
|
72141
|
+
const i0 = Math.max(-1, cx0);
|
|
72142
|
+
const j0 = Math.max(-1, cy0);
|
|
72143
|
+
const i1 = Math.min(width, cx1);
|
|
72144
|
+
const j1 = Math.min(height, cy1);
|
|
72145
|
+
const sample = (x, y) => {
|
|
72146
|
+
if (x < 0 || x >= width || y < 0 || y >= height)
|
|
72147
|
+
return false;
|
|
72148
|
+
return labels[(y * width + x) * stride + channelOffset] === targetLabel;
|
|
72149
|
+
};
|
|
72150
|
+
// Shift output by +0.5 so that marching-squares samples (voxel positions)
|
|
72151
|
+
// map to voxel-square CENTERS in render space. Voxel (i, j) occupies
|
|
72152
|
+
// render square [i, i+1] × [j, j+1]; its center is (i+0.5, j+0.5).
|
|
72153
|
+
const SHIFT = 0.5;
|
|
72154
|
+
for (let j = j0; j < j1; j++) {
|
|
72155
|
+
for (let i = i0; i < i1; i++) {
|
|
72156
|
+
const tl = sample(i, j);
|
|
72157
|
+
const tr = sample(i + 1, j);
|
|
72158
|
+
const br = sample(i + 1, j + 1);
|
|
72159
|
+
const bl = sample(i, j + 1);
|
|
72160
|
+
const code = (tl ? 8 : 0) |
|
|
72161
|
+
(tr ? 4 : 0) |
|
|
72162
|
+
(br ? 2 : 0) |
|
|
72163
|
+
(bl ? 1 : 0);
|
|
72164
|
+
if (code === 0)
|
|
72165
|
+
continue;
|
|
72166
|
+
const polygons = CELL_POLYGONS[code];
|
|
72167
|
+
for (let p = 0; p < polygons.length; p++) {
|
|
72168
|
+
const poly = polygons[p];
|
|
72169
|
+
const abs = new Array(poly.length);
|
|
72170
|
+
for (let k = 0; k < poly.length; k++) {
|
|
72171
|
+
abs[k] = [i + poly[k][0] + SHIFT, j + poly[k][1] + SHIFT];
|
|
72172
|
+
}
|
|
72173
|
+
out.push(abs);
|
|
72174
|
+
}
|
|
72175
|
+
}
|
|
72176
|
+
}
|
|
72177
|
+
return out;
|
|
72178
|
+
}
|
|
72179
|
+
/**
|
|
72180
|
+
* Extract a `Path2D` covering all voxels equal to `targetLabel`.
|
|
72181
|
+
*
|
|
72182
|
+
* @param labels Flat label array. Addressed as
|
|
72183
|
+
* `labels[(y * width + x) * stride + channelOffset]`.
|
|
72184
|
+
* @param width Grid width in voxels.
|
|
72185
|
+
* @param height Grid height in voxels.
|
|
72186
|
+
* @param targetLabel Label value to extract (1..255; 0 is background).
|
|
72187
|
+
* @param stride Bytes per voxel (default 1). Use MaskVolume.numChannels
|
|
72188
|
+
* when reading interleaved slices.
|
|
72189
|
+
* @param channelOffset Offset within each voxel's channels to sample.
|
|
72190
|
+
* Default 0 (first channel = label id).
|
|
72191
|
+
* @param bbox Optional voxel-space bbox to limit extraction.
|
|
72192
|
+
* Cells in a 1-voxel halo around the bbox are still
|
|
72193
|
+
* visited so the contour closes correctly at the bbox edges.
|
|
72194
|
+
*/
|
|
72195
|
+
function extractLabelContours(labels, width, height, targetLabel, stride = 1, channelOffset = 0, bbox) {
|
|
72196
|
+
const polygons = extractLabelPolygons(labels, width, height, targetLabel, stride, channelOffset, bbox);
|
|
72197
|
+
const path = new Path2D();
|
|
72198
|
+
for (let p = 0; p < polygons.length; p++) {
|
|
72199
|
+
const poly = polygons[p];
|
|
72200
|
+
path.moveTo(poly[0][0], poly[0][1]);
|
|
72201
|
+
for (let k = 1; k < poly.length; k++) {
|
|
72202
|
+
path.lineTo(poly[k][0], poly[k][1]);
|
|
72203
|
+
}
|
|
72204
|
+
path.closePath();
|
|
72205
|
+
}
|
|
72206
|
+
return path;
|
|
72207
|
+
}
|
|
72208
|
+
/**
|
|
72209
|
+
* Detect the distinct non-zero labels present in a slice region.
|
|
72210
|
+
*
|
|
72211
|
+
* @param labels Flat label array (see {@link extractLabelContours}).
|
|
72212
|
+
* @param width Grid width.
|
|
72213
|
+
* @param height Grid height.
|
|
72214
|
+
* @param stride Bytes per voxel.
|
|
72215
|
+
* @param channelOffset Offset within each voxel.
|
|
72216
|
+
* @param bbox Optional region to scan (defaults to full grid).
|
|
72217
|
+
* @returns Sorted array of distinct label values (0 omitted).
|
|
72218
|
+
*/
|
|
72219
|
+
function findLabelsInSlice(labels, width, height, stride = 1, channelOffset = 0, bbox) {
|
|
72220
|
+
var _a, _b, _c, _d;
|
|
72221
|
+
const x0 = (_a = bbox === null || bbox === void 0 ? void 0 : bbox.x0) !== null && _a !== void 0 ? _a : 0;
|
|
72222
|
+
const y0 = (_b = bbox === null || bbox === void 0 ? void 0 : bbox.y0) !== null && _b !== void 0 ? _b : 0;
|
|
72223
|
+
const x1 = (_c = bbox === null || bbox === void 0 ? void 0 : bbox.x1) !== null && _c !== void 0 ? _c : width;
|
|
72224
|
+
const y1 = (_d = bbox === null || bbox === void 0 ? void 0 : bbox.y1) !== null && _d !== void 0 ? _d : height;
|
|
72225
|
+
const seen = new Set();
|
|
72226
|
+
for (let y = y0; y < y1; y++) {
|
|
72227
|
+
for (let x = x0; x < x1; x++) {
|
|
72228
|
+
const v = labels[(y * width + x) * stride + channelOffset];
|
|
72229
|
+
if (v !== 0)
|
|
72230
|
+
seen.add(v);
|
|
72231
|
+
}
|
|
72232
|
+
}
|
|
72233
|
+
return Array.from(seen).sort((a, b) => a - b);
|
|
72234
|
+
}
|
|
72235
|
+
|
|
72067
72236
|
/**
|
|
72068
72237
|
* BaseTool - Abstract base for extracted DrawToolCore tools
|
|
72069
72238
|
*
|
|
@@ -73551,35 +73720,54 @@ class RenderingUtils {
|
|
|
73551
73720
|
}
|
|
73552
73721
|
}
|
|
73553
73722
|
/**
|
|
73554
|
-
* Render a layer's slice
|
|
73723
|
+
* Render a layer's slice onto the target canvas as vector contours.
|
|
73555
73724
|
*
|
|
73556
|
-
* Uses
|
|
73725
|
+
* Uses marching-squares to extract voxel-truthful Path2D contours per
|
|
73726
|
+
* label, then `ctx.fill()` them at the display canvas resolution. This
|
|
73727
|
+
* eliminates the bilinear-upscale blur and zoom-dependent shape drift
|
|
73728
|
+
* that plagued the old putImageData → drawImage pipeline.
|
|
73729
|
+
*
|
|
73730
|
+
* The `buffer` parameter is kept for backward-compatible signature but
|
|
73731
|
+
* is no longer used on this path — callers may pass any valid ImageData.
|
|
73557
73732
|
*/
|
|
73558
|
-
renderSliceToCanvas(layer, axis, sliceIndex,
|
|
73733
|
+
renderSliceToCanvas(layer, axis, sliceIndex, _buffer, targetCtx, scaledWidth, scaledHeight) {
|
|
73559
73734
|
try {
|
|
73560
73735
|
const volume = this.getVolumeForLayer(layer);
|
|
73561
73736
|
if (!volume)
|
|
73562
73737
|
return;
|
|
73563
|
-
// Get channel visibility for this layer
|
|
73564
73738
|
const channelVis = this.state.gui_states.layerChannel.channelVisibility[layer];
|
|
73565
|
-
|
|
73566
|
-
|
|
73567
|
-
|
|
73568
|
-
|
|
73739
|
+
const slice = volume.getSliceUint8(sliceIndex, axis);
|
|
73740
|
+
const W = slice.width;
|
|
73741
|
+
const H = slice.height;
|
|
73742
|
+
const stride = volume.getChannels();
|
|
73743
|
+
const labels = findLabelsInSlice(slice.data, W, H, stride, 0);
|
|
73744
|
+
if (labels.length === 0)
|
|
73745
|
+
return;
|
|
73746
|
+
targetCtx.save();
|
|
73747
|
+
// Vector fill — imageSmoothingEnabled is irrelevant here, but keep
|
|
73748
|
+
// it off to match the rest of the pipeline.
|
|
73569
73749
|
targetCtx.imageSmoothingEnabled = false;
|
|
73570
|
-
// Coronal (axis='y') Z-flip:
|
|
73571
|
-
//
|
|
73750
|
+
// Coronal (axis='y') Z-flip: mirrors the flip applied by the write
|
|
73751
|
+
// path (syncLayerSliceData). Apply BEFORE the voxel→display scale
|
|
73752
|
+
// so the flip operates in display coordinates.
|
|
73572
73753
|
if (axis === 'y') {
|
|
73573
|
-
targetCtx.save();
|
|
73574
73754
|
targetCtx.scale(1, -1);
|
|
73575
73755
|
targetCtx.translate(0, -scaledHeight);
|
|
73576
73756
|
}
|
|
73577
|
-
|
|
73578
|
-
|
|
73579
|
-
|
|
73757
|
+
// Voxel coord (x ∈ [0, W], y ∈ [0, H]) → display coord.
|
|
73758
|
+
targetCtx.scale(scaledWidth / W, scaledHeight / H);
|
|
73759
|
+
for (const lbl of labels) {
|
|
73760
|
+
if (channelVis && channelVis[lbl] === false)
|
|
73761
|
+
continue;
|
|
73762
|
+
const color = volume.getChannelColor(lbl);
|
|
73763
|
+
const path = extractLabelContours(slice.data, W, H, lbl, stride, 0);
|
|
73764
|
+
targetCtx.fillStyle =
|
|
73765
|
+
`rgba(${color.r}, ${color.g}, ${color.b}, ${color.a / 255})`;
|
|
73766
|
+
targetCtx.fill(path, 'nonzero');
|
|
73580
73767
|
}
|
|
73768
|
+
targetCtx.restore();
|
|
73581
73769
|
}
|
|
73582
|
-
catch (
|
|
73770
|
+
catch (_a) {
|
|
73583
73771
|
// Slice out of bounds or volume not ready — skip silently
|
|
73584
73772
|
}
|
|
73585
73773
|
}
|
|
@@ -73664,6 +73852,11 @@ const DEFAULT_KEYBOARD_SETTINGS = {
|
|
|
73664
73852
|
* Drawing tools that can be used with Shift+drag
|
|
73665
73853
|
*/
|
|
73666
73854
|
const DRAWING_TOOLS = new Set(['pencil', 'brush', 'eraser']);
|
|
73855
|
+
/**
|
|
73856
|
+
* Sphere-family tools (single-placement sphere + brush/eraser variants).
|
|
73857
|
+
* All three participate in the same crosshair / contrast mutual-exclusion rules.
|
|
73858
|
+
*/
|
|
73859
|
+
const SPHERE_TOOLS = new Set(['sphere', 'sphereBrush', 'sphereEraser']);
|
|
73667
73860
|
class EventRouter {
|
|
73668
73861
|
constructor(config) {
|
|
73669
73862
|
// === State ===
|
|
@@ -73794,8 +73987,8 @@ class EventRouter {
|
|
|
73794
73987
|
*/
|
|
73795
73988
|
setGuiTool(tool) {
|
|
73796
73989
|
this.guiTool = tool;
|
|
73797
|
-
// When entering sphere
|
|
73798
|
-
if (tool
|
|
73990
|
+
// When entering any sphere-family tool, keep crosshair if active, otherwise idle
|
|
73991
|
+
if (SPHERE_TOOLS.has(tool)) {
|
|
73799
73992
|
if (!this.state.crosshairEnabled) {
|
|
73800
73993
|
this.setMode('idle');
|
|
73801
73994
|
}
|
|
@@ -73807,10 +74000,12 @@ class EventRouter {
|
|
|
73807
74000
|
* Blocked when draw or contrast mode is active, or left button is held (mutual exclusion).
|
|
73808
74001
|
*/
|
|
73809
74002
|
toggleCrosshair() {
|
|
73810
|
-
// Allow crosshair in drawing tools and sphere
|
|
73811
|
-
if (!DRAWING_TOOLS.has(this.guiTool) && this.guiTool
|
|
74003
|
+
// Allow crosshair in drawing tools and all sphere-family tools (sphere / sphereBrush / sphereEraser)
|
|
74004
|
+
if (!DRAWING_TOOLS.has(this.guiTool) && !SPHERE_TOOLS.has(this.guiTool))
|
|
73812
74005
|
return;
|
|
73813
|
-
// Block crosshair activation during draw, contrast, or while left button held
|
|
74006
|
+
// Block crosshair activation during draw, contrast, or while left button held.
|
|
74007
|
+
// The leftButtonDown guard also enforces "once a sphere preview is on screen,
|
|
74008
|
+
// S is ignored until mouseup" for sphereBrush/sphereEraser.
|
|
73814
74009
|
if (this.state.shiftHeld || this.state.leftButtonDown || this.mode === 'draw' || this.mode === 'contrast')
|
|
73815
74010
|
return;
|
|
73816
74011
|
this.state.crosshairEnabled = !this.state.crosshairEnabled;
|
|
@@ -73937,9 +74132,9 @@ class EventRouter {
|
|
|
73937
74132
|
}
|
|
73938
74133
|
}
|
|
73939
74134
|
if (this.contrastEnabled && this.keyboardSettings.contrast.includes(ev.key)) {
|
|
73940
|
-
// Block contrast state when crosshair, draw, or sphere is active (mutual exclusion)
|
|
74135
|
+
// Block contrast state when crosshair, draw, or any sphere-family tool is active (mutual exclusion)
|
|
73941
74136
|
if (!this.state.crosshairEnabled && this.mode !== 'draw'
|
|
73942
|
-
&& this.guiTool
|
|
74137
|
+
&& !SPHERE_TOOLS.has(this.guiTool)) {
|
|
73943
74138
|
this.state.ctrlHeld = true;
|
|
73944
74139
|
}
|
|
73945
74140
|
}
|
|
@@ -74563,6 +74758,10 @@ class PanTool extends BaseTool {
|
|
|
74563
74758
|
* Extracted from DrawToolCore.paintOnCanvas() closure (Phase 3).
|
|
74564
74759
|
* Handles left-click drawing operations: pencil stroke + fill, brush strokes,
|
|
74565
74760
|
* eraser, and undo snapshot capture/push.
|
|
74761
|
+
*
|
|
74762
|
+
* Phase B: Brush mode now writes voxels directly during mousemove and
|
|
74763
|
+
* re-renders via marching squares, eliminating the visual "snap" between
|
|
74764
|
+
* the smooth canvas stroke and the voxel-based post-release render.
|
|
74566
74765
|
*/
|
|
74567
74766
|
class DrawingTool extends BaseTool {
|
|
74568
74767
|
constructor(ctx, callbacks) {
|
|
@@ -74579,6 +74778,8 @@ class DrawingTool extends BaseTool {
|
|
|
74579
74778
|
this.preDrawSlice = null;
|
|
74580
74779
|
this.preDrawAxis = "z";
|
|
74581
74780
|
this.preDrawSliceIndex = 0;
|
|
74781
|
+
/** Previous voxel-space position for Bresenham interpolation (brush mode) */
|
|
74782
|
+
this.prevVoxel = null;
|
|
74582
74783
|
this.callbacks = callbacks;
|
|
74583
74784
|
}
|
|
74584
74785
|
/** Whether a draw operation is currently active */
|
|
@@ -74598,6 +74799,7 @@ class DrawingTool extends BaseTool {
|
|
|
74598
74799
|
this.isPainting = false;
|
|
74599
74800
|
this.drawingLines = [];
|
|
74600
74801
|
this.clearArcFn = clearArcFn;
|
|
74802
|
+
this.prevVoxel = null;
|
|
74601
74803
|
}
|
|
74602
74804
|
/**
|
|
74603
74805
|
* Called on left-click pointerdown in draw mode.
|
|
@@ -74625,6 +74827,22 @@ class DrawingTool extends BaseTool {
|
|
|
74625
74827
|
this.ctx.nrrd_states.interaction.drawStartPos.y = e.offsetY;
|
|
74626
74828
|
// Capture pre-draw slice snapshot for undo
|
|
74627
74829
|
this.capturePreDrawSnapshot();
|
|
74830
|
+
// Brush mode: initialize voxel tracking and write first dot
|
|
74831
|
+
if (!this.ctx.gui_states.mode.pencil && !this.ctx.gui_states.mode.eraser) {
|
|
74832
|
+
const voxel = this.canvasToVoxel3D(e.offsetX, e.offsetY);
|
|
74833
|
+
this.prevVoxel = voxel;
|
|
74834
|
+
try {
|
|
74835
|
+
const vol = this.callbacks.getVolumeForLayer(this.ctx.gui_states.layerChannel.layer);
|
|
74836
|
+
const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
|
|
74837
|
+
const axis = this.ctx.protectedData.axis;
|
|
74838
|
+
const { rH, rV } = this.getVoxelBrushRadius();
|
|
74839
|
+
this.paintVoxelEllipse(vol, voxel, rH, rV, channel, axis);
|
|
74840
|
+
this.refreshLayerFromVolume();
|
|
74841
|
+
}
|
|
74842
|
+
catch (_a) {
|
|
74843
|
+
// Volume not ready
|
|
74844
|
+
}
|
|
74845
|
+
}
|
|
74628
74846
|
}
|
|
74629
74847
|
/**
|
|
74630
74848
|
* Called on pointermove during an active drawing operation.
|
|
@@ -74638,10 +74856,16 @@ class DrawingTool extends BaseTool {
|
|
|
74638
74856
|
this.ctx.nrrd_states.flags.stepClear = 1;
|
|
74639
74857
|
(_a = this.clearArcFn) === null || _a === void 0 ? void 0 : _a.call(this, e.offsetX, e.offsetY, this.ctx.gui_states.drawing.brushAndEraserSize);
|
|
74640
74858
|
}
|
|
74641
|
-
else {
|
|
74859
|
+
else if (this.ctx.gui_states.mode.pencil) {
|
|
74860
|
+
// Pencil mode: accumulate points and draw visual lines on canvas
|
|
74642
74861
|
this.drawingLines.push({ x: e.offsetX, y: e.offsetY });
|
|
74643
74862
|
this.paintOnCanvasLayer(e.offsetX, e.offsetY);
|
|
74644
74863
|
}
|
|
74864
|
+
else {
|
|
74865
|
+
// Brush mode: write voxels directly + re-render via marching squares
|
|
74866
|
+
this.drawingLines.push({ x: e.offsetX, y: e.offsetY });
|
|
74867
|
+
this.paintBrushVoxelMove(e.offsetX, e.offsetY);
|
|
74868
|
+
}
|
|
74645
74869
|
}
|
|
74646
74870
|
}
|
|
74647
74871
|
/**
|
|
@@ -74654,11 +74878,9 @@ class DrawingTool extends BaseTool {
|
|
|
74654
74878
|
ctx.closePath();
|
|
74655
74879
|
if (!this.ctx.gui_states.mode.eraser) {
|
|
74656
74880
|
if (this.ctx.gui_states.mode.pencil) {
|
|
74657
|
-
//
|
|
74881
|
+
// Pencil mode: fill polygon then bake to voxels
|
|
74658
74882
|
canvas.width = canvas.width;
|
|
74659
|
-
// Redraw previous layer data from volume
|
|
74660
74883
|
this.redrawPreviousImageToLayerCtx(ctx);
|
|
74661
|
-
// Draw new pencil strokes on current layer canvas
|
|
74662
74884
|
ctx.beginPath();
|
|
74663
74885
|
ctx.moveTo(this.drawingLines[0].x, this.drawingLines[0].y);
|
|
74664
74886
|
for (let i = 1; i < this.drawingLines.length; i++) {
|
|
@@ -74668,12 +74890,26 @@ class DrawingTool extends BaseTool {
|
|
|
74668
74890
|
ctx.lineWidth = 1;
|
|
74669
74891
|
ctx.fillStyle = this.ctx.gui_states.drawing.fillColor;
|
|
74670
74892
|
ctx.fill();
|
|
74671
|
-
// Composite ALL layers to master (not just current layer)
|
|
74672
74893
|
this.callbacks.compositeAllLayers();
|
|
74894
|
+
// Pencil still needs canvas→voxel bake
|
|
74895
|
+
this.callbacks.syncLayerSliceData(this.ctx.nrrd_states.view.currentSliceIndex, this.ctx.gui_states.layerChannel.layer);
|
|
74896
|
+
// Re-render from voxels to eliminate pencil snap
|
|
74897
|
+
this.refreshLayerFromVolume();
|
|
74673
74898
|
}
|
|
74899
|
+
else {
|
|
74900
|
+
// Brush mode: voxels already written during mousemove
|
|
74901
|
+
// Just re-render final state and composite
|
|
74902
|
+
this.refreshLayerFromVolume();
|
|
74903
|
+
}
|
|
74904
|
+
}
|
|
74905
|
+
else {
|
|
74906
|
+
// Eraser mode: still needs canvas→voxel bake
|
|
74907
|
+
this.callbacks.syncLayerSliceData(this.ctx.nrrd_states.view.currentSliceIndex, this.ctx.gui_states.layerChannel.layer);
|
|
74908
|
+
// Re-render from voxels for eraser too
|
|
74909
|
+
this.refreshLayerFromVolume();
|
|
74674
74910
|
}
|
|
74675
|
-
this.callbacks.syncLayerSliceData(this.ctx.nrrd_states.view.currentSliceIndex, this.ctx.gui_states.layerChannel.layer);
|
|
74676
74911
|
this.isPainting = false;
|
|
74912
|
+
this.prevVoxel = null;
|
|
74677
74913
|
// Push undo delta
|
|
74678
74914
|
this.pushUndoDelta();
|
|
74679
74915
|
}
|
|
@@ -74684,6 +74920,7 @@ class DrawingTool extends BaseTool {
|
|
|
74684
74920
|
*/
|
|
74685
74921
|
onPointerLeave() {
|
|
74686
74922
|
this.isPainting = false;
|
|
74923
|
+
this.prevVoxel = null;
|
|
74687
74924
|
if (!this.leftClicked)
|
|
74688
74925
|
return false;
|
|
74689
74926
|
this.leftClicked = false;
|
|
@@ -74694,11 +74931,6 @@ class DrawingTool extends BaseTool {
|
|
|
74694
74931
|
/**
|
|
74695
74932
|
* Create a self-managing mouseover/mouseout/mousemove handler
|
|
74696
74933
|
* that tracks brush hover position for the preview circle.
|
|
74697
|
-
*
|
|
74698
|
-
* The returned function should be registered on drawingCanvas for
|
|
74699
|
-
* "mouseover" and "mouseout" events. It adds/removes a "mousemove"
|
|
74700
|
-
* listener on itself to keep mouseOverX/Y up-to-date while the
|
|
74701
|
-
* cursor is inside the canvas.
|
|
74702
74934
|
*/
|
|
74703
74935
|
createBrushTrackingHandler() {
|
|
74704
74936
|
const handler = (e) => {
|
|
@@ -74722,8 +74954,6 @@ class DrawingTool extends BaseTool {
|
|
|
74722
74954
|
}
|
|
74723
74955
|
/**
|
|
74724
74956
|
* Render brush circle preview on the drawing context.
|
|
74725
|
-
* Called from the start() render loop when in draw mode and not
|
|
74726
|
-
* actively painting. Skipped in pencil/eraser mode.
|
|
74727
74957
|
*/
|
|
74728
74958
|
renderBrushPreview(ctx, width, height) {
|
|
74729
74959
|
if (this.ctx.gui_states.mode.pencil ||
|
|
@@ -74738,6 +74968,195 @@ class DrawingTool extends BaseTool {
|
|
|
74738
74968
|
ctx.strokeStyle = this.ctx.gui_states.drawing.brushColor;
|
|
74739
74969
|
ctx.stroke();
|
|
74740
74970
|
}
|
|
74971
|
+
// ── Brush mode: direct voxel write ────────────────────────────
|
|
74972
|
+
/**
|
|
74973
|
+
* Convert display (zoomed) coordinates to 3D voxel coordinates.
|
|
74974
|
+
* Same logic as SphereBrushTool.canvasToVoxelCenter.
|
|
74975
|
+
*/
|
|
74976
|
+
canvasToVoxel3D(displayX, displayY) {
|
|
74977
|
+
const nrrd = this.ctx.nrrd_states;
|
|
74978
|
+
const axis = this.ctx.protectedData.axis;
|
|
74979
|
+
const cw = nrrd.view.changedWidth;
|
|
74980
|
+
const ch = nrrd.view.changedHeight;
|
|
74981
|
+
const sliceIndex = nrrd.view.currentSliceIndex;
|
|
74982
|
+
switch (axis) {
|
|
74983
|
+
case 'z':
|
|
74984
|
+
return {
|
|
74985
|
+
x: displayX * nrrd.image.nrrd_x_pixel / cw,
|
|
74986
|
+
y: displayY * nrrd.image.nrrd_y_pixel / ch,
|
|
74987
|
+
z: sliceIndex,
|
|
74988
|
+
};
|
|
74989
|
+
case 'y':
|
|
74990
|
+
return {
|
|
74991
|
+
x: displayX * nrrd.image.nrrd_x_pixel / cw,
|
|
74992
|
+
y: sliceIndex,
|
|
74993
|
+
z: (ch - displayY) * nrrd.image.nrrd_z_pixel / ch,
|
|
74994
|
+
};
|
|
74995
|
+
case 'x':
|
|
74996
|
+
return {
|
|
74997
|
+
x: sliceIndex,
|
|
74998
|
+
y: displayY * nrrd.image.nrrd_y_pixel / ch,
|
|
74999
|
+
z: displayX * nrrd.image.nrrd_z_pixel / cw,
|
|
75000
|
+
};
|
|
75001
|
+
}
|
|
75002
|
+
}
|
|
75003
|
+
/**
|
|
75004
|
+
* Get voxel-space brush radius for visible axes.
|
|
75005
|
+
* Converts display-pixel brush size to voxel units.
|
|
75006
|
+
*/
|
|
75007
|
+
getVoxelBrushRadius() {
|
|
75008
|
+
const nrrd = this.ctx.nrrd_states;
|
|
75009
|
+
const axis = this.ctx.protectedData.axis;
|
|
75010
|
+
const cw = nrrd.view.changedWidth;
|
|
75011
|
+
const ch = nrrd.view.changedHeight;
|
|
75012
|
+
const displayRadius = this.ctx.gui_states.drawing.brushAndEraserSize / 2;
|
|
75013
|
+
switch (axis) {
|
|
75014
|
+
case 'z':
|
|
75015
|
+
return {
|
|
75016
|
+
rH: displayRadius * nrrd.image.nrrd_x_pixel / cw,
|
|
75017
|
+
rV: displayRadius * nrrd.image.nrrd_y_pixel / ch,
|
|
75018
|
+
};
|
|
75019
|
+
case 'y':
|
|
75020
|
+
return {
|
|
75021
|
+
rH: displayRadius * nrrd.image.nrrd_x_pixel / cw,
|
|
75022
|
+
rV: displayRadius * nrrd.image.nrrd_z_pixel / ch,
|
|
75023
|
+
};
|
|
75024
|
+
case 'x':
|
|
75025
|
+
return {
|
|
75026
|
+
rH: displayRadius * nrrd.image.nrrd_z_pixel / cw,
|
|
75027
|
+
rV: displayRadius * nrrd.image.nrrd_y_pixel / ch,
|
|
75028
|
+
};
|
|
75029
|
+
}
|
|
75030
|
+
}
|
|
75031
|
+
/**
|
|
75032
|
+
* Paint a 2D ellipse of voxels on the current slice.
|
|
75033
|
+
* Only the two visible axes are iterated; the slice-direction axis is fixed.
|
|
75034
|
+
*/
|
|
75035
|
+
paintVoxelEllipse(vol, center, rH, rV, label, axis) {
|
|
75036
|
+
const dims = vol.getDimensions();
|
|
75037
|
+
switch (axis) {
|
|
75038
|
+
case 'z': {
|
|
75039
|
+
const minX = Math.max(0, Math.floor(center.x - rH));
|
|
75040
|
+
const maxX = Math.min(dims.width - 1, Math.ceil(center.x + rH));
|
|
75041
|
+
const minY = Math.max(0, Math.floor(center.y - rV));
|
|
75042
|
+
const maxY = Math.min(dims.height - 1, Math.ceil(center.y + rV));
|
|
75043
|
+
const z = Math.round(center.z);
|
|
75044
|
+
for (let y = minY; y <= maxY; y++) {
|
|
75045
|
+
for (let x = minX; x <= maxX; x++) {
|
|
75046
|
+
const dx = rH > 0 ? (x - center.x) / rH : 0;
|
|
75047
|
+
const dy = rV > 0 ? (y - center.y) / rV : 0;
|
|
75048
|
+
if (dx * dx + dy * dy <= 1.0) {
|
|
75049
|
+
vol.setVoxel(x, y, z, label);
|
|
75050
|
+
}
|
|
75051
|
+
}
|
|
75052
|
+
}
|
|
75053
|
+
break;
|
|
75054
|
+
}
|
|
75055
|
+
case 'y': {
|
|
75056
|
+
const minX = Math.max(0, Math.floor(center.x - rH));
|
|
75057
|
+
const maxX = Math.min(dims.width - 1, Math.ceil(center.x + rH));
|
|
75058
|
+
const minZ = Math.max(0, Math.floor(center.z - rV));
|
|
75059
|
+
const maxZ = Math.min(dims.depth - 1, Math.ceil(center.z + rV));
|
|
75060
|
+
const y = Math.round(center.y);
|
|
75061
|
+
for (let z = minZ; z <= maxZ; z++) {
|
|
75062
|
+
for (let x = minX; x <= maxX; x++) {
|
|
75063
|
+
const dx = rH > 0 ? (x - center.x) / rH : 0;
|
|
75064
|
+
const dz = rV > 0 ? (z - center.z) / rV : 0;
|
|
75065
|
+
if (dx * dx + dz * dz <= 1.0) {
|
|
75066
|
+
vol.setVoxel(x, y, z, label);
|
|
75067
|
+
}
|
|
75068
|
+
}
|
|
75069
|
+
}
|
|
75070
|
+
break;
|
|
75071
|
+
}
|
|
75072
|
+
case 'x': {
|
|
75073
|
+
const minZ = Math.max(0, Math.floor(center.z - rH));
|
|
75074
|
+
const maxZ = Math.min(dims.depth - 1, Math.ceil(center.z + rH));
|
|
75075
|
+
const minY = Math.max(0, Math.floor(center.y - rV));
|
|
75076
|
+
const maxY = Math.min(dims.height - 1, Math.ceil(center.y + rV));
|
|
75077
|
+
const x = Math.round(center.x);
|
|
75078
|
+
for (let yy = minY; yy <= maxY; yy++) {
|
|
75079
|
+
for (let zz = minZ; zz <= maxZ; zz++) {
|
|
75080
|
+
const dz = rH > 0 ? (zz - center.z) / rH : 0;
|
|
75081
|
+
const dy = rV > 0 ? (yy - center.y) / rV : 0;
|
|
75082
|
+
if (dz * dz + dy * dy <= 1.0) {
|
|
75083
|
+
vol.setVoxel(x, yy, zz, label);
|
|
75084
|
+
}
|
|
75085
|
+
}
|
|
75086
|
+
}
|
|
75087
|
+
break;
|
|
75088
|
+
}
|
|
75089
|
+
}
|
|
75090
|
+
}
|
|
75091
|
+
/**
|
|
75092
|
+
* Brush mode mousemove: write voxels along stroke segment, then re-render.
|
|
75093
|
+
* Uses linear interpolation between prev and current positions to ensure
|
|
75094
|
+
* no gaps when the mouse moves fast.
|
|
75095
|
+
*/
|
|
75096
|
+
paintBrushVoxelMove(displayX, displayY) {
|
|
75097
|
+
try {
|
|
75098
|
+
const vol = this.callbacks.getVolumeForLayer(this.ctx.gui_states.layerChannel.layer);
|
|
75099
|
+
const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
|
|
75100
|
+
const axis = this.ctx.protectedData.axis;
|
|
75101
|
+
const { rH, rV } = this.getVoxelBrushRadius();
|
|
75102
|
+
const current = this.canvasToVoxel3D(displayX, displayY);
|
|
75103
|
+
if (this.prevVoxel) {
|
|
75104
|
+
// Interpolate from prev to current to fill gaps
|
|
75105
|
+
const prev = this.prevVoxel;
|
|
75106
|
+
let steps;
|
|
75107
|
+
switch (axis) {
|
|
75108
|
+
case 'z':
|
|
75109
|
+
steps = Math.max(Math.abs(current.x - prev.x), Math.abs(current.y - prev.y));
|
|
75110
|
+
break;
|
|
75111
|
+
case 'y':
|
|
75112
|
+
steps = Math.max(Math.abs(current.x - prev.x), Math.abs(current.z - prev.z));
|
|
75113
|
+
break;
|
|
75114
|
+
case 'x':
|
|
75115
|
+
steps = Math.max(Math.abs(current.z - prev.z), Math.abs(current.y - prev.y));
|
|
75116
|
+
break;
|
|
75117
|
+
}
|
|
75118
|
+
steps = Math.max(1, Math.ceil(steps));
|
|
75119
|
+
for (let i = 0; i <= steps; i++) {
|
|
75120
|
+
const t = steps === 0 ? 0 : i / steps;
|
|
75121
|
+
const pt = {
|
|
75122
|
+
x: prev.x + (current.x - prev.x) * t,
|
|
75123
|
+
y: prev.y + (current.y - prev.y) * t,
|
|
75124
|
+
z: prev.z + (current.z - prev.z) * t,
|
|
75125
|
+
};
|
|
75126
|
+
this.paintVoxelEllipse(vol, pt, rH, rV, channel, axis);
|
|
75127
|
+
}
|
|
75128
|
+
}
|
|
75129
|
+
else {
|
|
75130
|
+
this.paintVoxelEllipse(vol, current, rH, rV, channel, axis);
|
|
75131
|
+
}
|
|
75132
|
+
this.prevVoxel = current;
|
|
75133
|
+
// Re-render the layer canvas from volume (marching squares)
|
|
75134
|
+
this.refreshLayerFromVolume();
|
|
75135
|
+
}
|
|
75136
|
+
catch (_a) {
|
|
75137
|
+
// Volume not ready — fall back to canvas drawing
|
|
75138
|
+
this.paintOnCanvasLayer(displayX, displayY);
|
|
75139
|
+
}
|
|
75140
|
+
}
|
|
75141
|
+
/**
|
|
75142
|
+
* Re-render the active layer's canvas from MaskVolume via marching squares,
|
|
75143
|
+
* then composite all layers to master.
|
|
75144
|
+
*/
|
|
75145
|
+
refreshLayerFromVolume() {
|
|
75146
|
+
const layer = this.ctx.gui_states.layerChannel.layer;
|
|
75147
|
+
const target = this.ctx.protectedData.layerTargets.get(layer);
|
|
75148
|
+
if (!target)
|
|
75149
|
+
return;
|
|
75150
|
+
const { ctx, canvas } = target;
|
|
75151
|
+
canvas.width = canvas.width; // clear
|
|
75152
|
+
const axis = this.ctx.protectedData.axis;
|
|
75153
|
+
const buffer = this.callbacks.getOrCreateSliceBuffer(axis);
|
|
75154
|
+
if (buffer) {
|
|
75155
|
+
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);
|
|
75156
|
+
}
|
|
75157
|
+
this.callbacks.compositeAllLayers();
|
|
75158
|
+
this.ctx.protectedData.mainPreSlices.mesh.material.map.needsUpdate = true;
|
|
75159
|
+
}
|
|
74741
75160
|
// ── Private helpers ────────────────────────────────────────
|
|
74742
75161
|
/** Capture pre-draw slice snapshot for undo */
|
|
74743
75162
|
capturePreDrawSnapshot() {
|
|
@@ -74774,27 +75193,18 @@ class DrawingTool extends BaseTool {
|
|
|
74774
75193
|
}
|
|
74775
75194
|
/**
|
|
74776
75195
|
* Redraws persisted layer data onto ctx before new pencil fill.
|
|
74777
|
-
*
|
|
75196
|
+
* Delegates to the host's vector renderSliceToCanvas for consistency.
|
|
74778
75197
|
*/
|
|
74779
75198
|
redrawPreviousImageToLayerCtx(ctx) {
|
|
74780
|
-
|
|
74781
|
-
const
|
|
74782
|
-
this.ctx.
|
|
74783
|
-
|
|
74784
|
-
|
|
74785
|
-
|
|
74786
|
-
|
|
74787
|
-
if (this.ctx.protectedData.axis === 'y') {
|
|
74788
|
-
ctx.save();
|
|
74789
|
-
ctx.scale(1, -1);
|
|
74790
|
-
ctx.translate(0, -this.ctx.nrrd_states.view.changedHeight);
|
|
74791
|
-
}
|
|
74792
|
-
ctx.drawImage(this.ctx.protectedData.canvases.emptyCanvas, 0, 0, this.ctx.nrrd_states.view.changedWidth, this.ctx.nrrd_states.view.changedHeight);
|
|
74793
|
-
if (this.ctx.protectedData.axis === 'y') {
|
|
74794
|
-
ctx.restore();
|
|
74795
|
-
}
|
|
75199
|
+
const axis = this.ctx.protectedData.axis;
|
|
75200
|
+
const W = this.ctx.nrrd_states.view.changedWidth;
|
|
75201
|
+
const H = this.ctx.nrrd_states.view.changedHeight;
|
|
75202
|
+
const buffer = this.callbacks.getOrCreateSliceBuffer(axis);
|
|
75203
|
+
if (!buffer)
|
|
75204
|
+
return;
|
|
75205
|
+
this.callbacks.renderSliceToCanvas(this.ctx.gui_states.layerChannel.layer, axis, this.ctx.nrrd_states.view.currentSliceIndex, buffer, ctx, W, H);
|
|
74796
75206
|
}
|
|
74797
|
-
/** Draw a line segment on a layer canvas */
|
|
75207
|
+
/** Draw a line segment on a layer canvas (pencil mode only) */
|
|
74798
75208
|
drawLinesOnLayer(ctx, x, y) {
|
|
74799
75209
|
ctx.beginPath();
|
|
74800
75210
|
ctx.moveTo(this.ctx.nrrd_states.interaction.drawStartPos.x, this.ctx.nrrd_states.interaction.drawStartPos.y);
|
|
@@ -74810,17 +75220,13 @@ class DrawingTool extends BaseTool {
|
|
|
74810
75220
|
ctx.stroke();
|
|
74811
75221
|
ctx.closePath();
|
|
74812
75222
|
}
|
|
74813
|
-
/** Paint a segment on the current layer canvas and composite to master */
|
|
75223
|
+
/** Paint a segment on the current layer canvas and composite to master (pencil mode) */
|
|
74814
75224
|
paintOnCanvasLayer(x, y) {
|
|
74815
75225
|
const { ctx } = this.callbacks.setCurrentLayer();
|
|
74816
|
-
// Draw only on the current layer canvas (not master directly)
|
|
74817
75226
|
this.drawLinesOnLayer(ctx, x, y);
|
|
74818
|
-
// Composite all layers to master to preserve other layers' data
|
|
74819
75227
|
this.callbacks.compositeAllLayers();
|
|
74820
|
-
// Reset drawing start position to current position
|
|
74821
75228
|
this.ctx.nrrd_states.interaction.drawStartPos.x = x;
|
|
74822
75229
|
this.ctx.nrrd_states.interaction.drawStartPos.y = y;
|
|
74823
|
-
// Flag the map as needing updating
|
|
74824
75230
|
this.ctx.protectedData.mainPreSlices.mesh.material.map.needsUpdate = true;
|
|
74825
75231
|
}
|
|
74826
75232
|
}
|
|
@@ -74960,7 +75366,11 @@ class ImageStoreHelper extends BaseTool {
|
|
|
74960
75366
|
class SphereBrushTool extends BaseTool {
|
|
74961
75367
|
constructor(ctx, callbacks) {
|
|
74962
75368
|
super(ctx);
|
|
74963
|
-
/**
|
|
75369
|
+
/**
|
|
75370
|
+
* Recorded sphere center in display (zoomed) coordinates.
|
|
75371
|
+
* Used for both preview redraw and voxel calculation — the voxel conversion
|
|
75372
|
+
* uses `changedWidth/Height` as the display scale, so no mm detour is needed.
|
|
75373
|
+
*/
|
|
74964
75374
|
this.centerX = 0;
|
|
74965
75375
|
this.centerY = 0;
|
|
74966
75376
|
this.centerSlice = 0;
|
|
@@ -74968,10 +75378,24 @@ class SphereBrushTool extends BaseTool {
|
|
|
74968
75378
|
this.active = false;
|
|
74969
75379
|
/** Current operation mode for the active placement */
|
|
74970
75380
|
this.mode = "brush";
|
|
74971
|
-
/** Cumulative "before" snapshots for drag
|
|
75381
|
+
/** Cumulative "before" snapshots for drag undo (z-index → slice data) — used by both brush and eraser drag */
|
|
74972
75382
|
this.dragBeforeSnapshots = new Map();
|
|
74973
|
-
/** Whether a drag
|
|
75383
|
+
/** Whether a drag has actually moved (vs. simple click-release) */
|
|
74974
75384
|
this.dragMoved = false;
|
|
75385
|
+
/**
|
|
75386
|
+
* Latest cursor position in display (zoomed) coords. Used by the wheel
|
|
75387
|
+
* handler to redraw the preview at the cursor, not the original mousedown
|
|
75388
|
+
* point, during mid-drag radius changes.
|
|
75389
|
+
*/
|
|
75390
|
+
this.lastDisplayX = 0;
|
|
75391
|
+
this.lastDisplayY = 0;
|
|
75392
|
+
this.lastSliceIndex = 0;
|
|
75393
|
+
/**
|
|
75394
|
+
* All sphere centers written during the current stroke. The wheel handler
|
|
75395
|
+
* retroactively re-renders the entire stroke at the new radius by restoring
|
|
75396
|
+
* `dragBeforeSnapshots` and replaying every entry here.
|
|
75397
|
+
*/
|
|
75398
|
+
this.strokeCenters = [];
|
|
74975
75399
|
this.callbacks = callbacks;
|
|
74976
75400
|
}
|
|
74977
75401
|
setCallbacks(callbacks) {
|
|
@@ -74979,41 +75403,66 @@ class SphereBrushTool extends BaseTool {
|
|
|
74979
75403
|
}
|
|
74980
75404
|
// ── Geometry (ported from SphereTool) ──────────────────────────
|
|
74981
75405
|
/**
|
|
74982
|
-
* Convert
|
|
75406
|
+
* Convert display (zoomed) coordinates to 3D voxel coordinates.
|
|
75407
|
+
*
|
|
75408
|
+
* Uses `changedWidth/Height` as the display scale directly (instead of
|
|
75409
|
+
* going through mm via `/sizeFactor`). This keeps the write pixel-exact
|
|
75410
|
+
* with the preview circle, which is drawn on a `changedWidth x changedHeight`
|
|
75411
|
+
* sized sphere canvas — avoiding floating-point drift at high zoom levels.
|
|
74983
75412
|
*/
|
|
74984
|
-
canvasToVoxelCenter(
|
|
75413
|
+
canvasToVoxelCenter(displayX, displayY, sliceIndex, axis) {
|
|
74985
75414
|
const nrrd = this.ctx.nrrd_states;
|
|
75415
|
+
const cw = nrrd.view.changedWidth;
|
|
75416
|
+
const ch = nrrd.view.changedHeight;
|
|
74986
75417
|
switch (axis) {
|
|
74987
75418
|
case 'z':
|
|
74988
75419
|
return {
|
|
74989
|
-
x:
|
|
74990
|
-
y:
|
|
75420
|
+
x: displayX * nrrd.image.nrrd_x_pixel / cw,
|
|
75421
|
+
y: displayY * nrrd.image.nrrd_y_pixel / ch,
|
|
74991
75422
|
z: sliceIndex,
|
|
74992
75423
|
};
|
|
74993
75424
|
case 'y':
|
|
74994
75425
|
return {
|
|
74995
|
-
x:
|
|
75426
|
+
x: displayX * nrrd.image.nrrd_x_pixel / cw,
|
|
74996
75427
|
y: sliceIndex,
|
|
74997
|
-
|
|
75428
|
+
// Coronal vertical flip: display Y=0 is top, voxel Z increases downward in mm-space
|
|
75429
|
+
z: (ch - displayY) * nrrd.image.nrrd_z_pixel / ch,
|
|
74998
75430
|
};
|
|
74999
75431
|
case 'x':
|
|
75000
75432
|
return {
|
|
75001
75433
|
x: sliceIndex,
|
|
75002
|
-
y:
|
|
75003
|
-
z:
|
|
75434
|
+
y: displayY * nrrd.image.nrrd_y_pixel / ch,
|
|
75435
|
+
z: displayX * nrrd.image.nrrd_z_pixel / cw,
|
|
75004
75436
|
};
|
|
75005
75437
|
}
|
|
75006
75438
|
}
|
|
75007
75439
|
/**
|
|
75008
75440
|
* Convert mm radius to per-axis voxel radii.
|
|
75441
|
+
*
|
|
75442
|
+
* For the two axes visible in the current view, scale via
|
|
75443
|
+
* `changedWidth/Height` so the rendered ellipsoid cross-section matches
|
|
75444
|
+
* the preview pixel radius exactly. The third (slice-direction) axis is
|
|
75445
|
+
* not visible, so it uses the plain mm-based ratio.
|
|
75009
75446
|
*/
|
|
75010
|
-
getVoxelRadii(radius) {
|
|
75447
|
+
getVoxelRadii(radius, axis) {
|
|
75011
75448
|
const nrrd = this.ctx.nrrd_states;
|
|
75012
|
-
|
|
75013
|
-
|
|
75014
|
-
|
|
75015
|
-
|
|
75016
|
-
|
|
75449
|
+
const sf = nrrd.view.sizeFactor;
|
|
75450
|
+
const cw = nrrd.view.changedWidth;
|
|
75451
|
+
const ch = nrrd.view.changedHeight;
|
|
75452
|
+
// pixel-exact voxel radius for the visible horizontal/vertical dims
|
|
75453
|
+
const rxView = radius * sf * nrrd.image.nrrd_x_pixel / cw;
|
|
75454
|
+
const ryView = radius * sf * nrrd.image.nrrd_y_pixel / ch;
|
|
75455
|
+
const rzHoriz = radius * sf * nrrd.image.nrrd_z_pixel / cw;
|
|
75456
|
+
const rzVert = radius * sf * nrrd.image.nrrd_z_pixel / ch;
|
|
75457
|
+
// mm-based fallback for the slice-direction (perpendicular) dim
|
|
75458
|
+
const rxMm = radius * nrrd.image.nrrd_x_pixel / nrrd.image.nrrd_x_mm;
|
|
75459
|
+
const ryMm = radius * nrrd.image.nrrd_y_pixel / nrrd.image.nrrd_y_mm;
|
|
75460
|
+
const rzMm = radius * nrrd.image.nrrd_z_pixel / nrrd.image.nrrd_z_mm;
|
|
75461
|
+
switch (axis) {
|
|
75462
|
+
case 'z': return { rx: rxView, ry: ryView, rz: rzMm };
|
|
75463
|
+
case 'y': return { rx: rxView, ry: ryMm, rz: rzVert };
|
|
75464
|
+
case 'x': return { rx: rxMm, ry: ryView, rz: rzHoriz };
|
|
75465
|
+
}
|
|
75017
75466
|
}
|
|
75018
75467
|
/**
|
|
75019
75468
|
* Compute clamped bounding box for an ellipsoid in voxel space.
|
|
@@ -75118,11 +75567,15 @@ class SphereBrushTool extends BaseTool {
|
|
|
75118
75567
|
drawPreview(mouseX, mouseY, radius, isEraser) {
|
|
75119
75568
|
const sphereCanvas = this.ctx.protectedData.canvases.drawingSphereCanvas;
|
|
75120
75569
|
const sphereCtx = this.ctx.protectedData.ctxes.drawingSphereCtx;
|
|
75121
|
-
//
|
|
75122
|
-
|
|
75123
|
-
|
|
75570
|
+
// Size sphere canvas to the zoomed display size so start() draws it 1:1 —
|
|
75571
|
+
// no scaling step, eliminating any zoom-dependent positioning error.
|
|
75572
|
+
const sf = this.ctx.nrrd_states.view.sizeFactor;
|
|
75573
|
+
sphereCanvas.width = this.ctx.nrrd_states.view.changedWidth;
|
|
75574
|
+
sphereCanvas.height = this.ctx.nrrd_states.view.changedHeight;
|
|
75575
|
+
// mouseX/mouseY are already in zoomed display coords; scale radius to match.
|
|
75576
|
+
const scaledRadius = radius * sf;
|
|
75124
75577
|
sphereCtx.beginPath();
|
|
75125
|
-
sphereCtx.arc(mouseX, mouseY,
|
|
75578
|
+
sphereCtx.arc(mouseX, mouseY, scaledRadius, 0, 2 * Math.PI);
|
|
75126
75579
|
if (isEraser) {
|
|
75127
75580
|
// Eraser preview: dashed outline
|
|
75128
75581
|
sphereCtx.strokeStyle = "#ff4444";
|
|
@@ -75175,38 +75628,135 @@ class SphereBrushTool extends BaseTool {
|
|
|
75175
75628
|
return (e) => {
|
|
75176
75629
|
e.preventDefault();
|
|
75177
75630
|
const sphere = this.ctx.nrrd_states.sphere;
|
|
75178
|
-
|
|
75179
|
-
|
|
75180
|
-
|
|
75181
|
-
|
|
75182
|
-
|
|
75631
|
+
sphere.sphereBrushRadius = Math.max(1, Math.min(sphere.sphereBrushRadius + (e.deltaY < 0 ? 1 : -1), 50));
|
|
75632
|
+
// 3D Slicer-style retroactive resize: if the user has already started
|
|
75633
|
+
// painting/erasing a stroke, re-render the entire stroke at the new
|
|
75634
|
+
// radius so the whole pipe/erase-path resizes together.
|
|
75635
|
+
if (this.active && this.dragMoved && this.strokeCenters.length > 0) {
|
|
75636
|
+
this.replayStrokeWithCurrentRadius();
|
|
75183
75637
|
}
|
|
75184
|
-
|
|
75185
|
-
//
|
|
75638
|
+
// Preview always follows the latest cursor position, not the mousedown
|
|
75639
|
+
// point — otherwise the preview circle appears stuck on click-down when
|
|
75640
|
+
// the user scrolls after dragging elsewhere.
|
|
75186
75641
|
if (this.active) {
|
|
75187
|
-
this.drawPreview(this.
|
|
75642
|
+
this.drawPreview(this.lastDisplayX, this.lastDisplayY, sphere.sphereBrushRadius, this.mode === "eraser");
|
|
75188
75643
|
}
|
|
75189
75644
|
};
|
|
75190
75645
|
}
|
|
75646
|
+
/**
|
|
75647
|
+
* Retroactively re-render the current stroke at the current radius.
|
|
75648
|
+
*
|
|
75649
|
+
* Step 1: walk every recorded stroke center and expand
|
|
75650
|
+
* `dragBeforeSnapshots` to cover the new (larger) bounding box. Slices
|
|
75651
|
+
* newly included here are still pristine because the original stroke
|
|
75652
|
+
* never touched them.
|
|
75653
|
+
* Step 2: restore every snapshotted slice from its pristine backup.
|
|
75654
|
+
* Step 3: replay every stroke center at the new radius.
|
|
75655
|
+
*
|
|
75656
|
+
* Step 3 produces the same final state as if the user had used the new
|
|
75657
|
+
* radius from the start. Undo still captures the full stroke as one group
|
|
75658
|
+
* at pointerup.
|
|
75659
|
+
*/
|
|
75660
|
+
replayStrokeWithCurrentRadius() {
|
|
75661
|
+
if (this.strokeCenters.length === 0)
|
|
75662
|
+
return;
|
|
75663
|
+
const layer = this.ctx.gui_states.layerChannel.layer;
|
|
75664
|
+
const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
|
|
75665
|
+
const axis = this.ctx.protectedData.axis;
|
|
75666
|
+
const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
|
|
75667
|
+
let vol;
|
|
75668
|
+
try {
|
|
75669
|
+
vol = this.callbacks.getVolumeForLayer(layer);
|
|
75670
|
+
}
|
|
75671
|
+
catch (_a) {
|
|
75672
|
+
return;
|
|
75673
|
+
}
|
|
75674
|
+
const dims = vol.getDimensions();
|
|
75675
|
+
// 1. Expand snapshot coverage using the new radius so any newly-exposed
|
|
75676
|
+
// Z slices are captured while still pristine.
|
|
75677
|
+
for (const sc of this.strokeCenters) {
|
|
75678
|
+
const center = this.canvasToVoxelCenter(sc.x, sc.y, sc.sliceIndex, axis);
|
|
75679
|
+
const radii = this.getVoxelRadii(radius, axis);
|
|
75680
|
+
const bb = this.getBoundingBox(center, radii, dims);
|
|
75681
|
+
this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
|
|
75682
|
+
}
|
|
75683
|
+
// 2. Restore every snapshotted slice from pristine backup.
|
|
75684
|
+
for (const [z, pristine] of this.dragBeforeSnapshots) {
|
|
75685
|
+
try {
|
|
75686
|
+
vol.setSliceUint8(z, pristine, 'z');
|
|
75687
|
+
}
|
|
75688
|
+
catch (_b) {
|
|
75689
|
+
// Slice write failed — skip silently so a single bad slice doesn't break the replay.
|
|
75690
|
+
}
|
|
75691
|
+
}
|
|
75692
|
+
// 3. Replay every stroke center with the new radius.
|
|
75693
|
+
for (const sc of this.strokeCenters) {
|
|
75694
|
+
const center = this.canvasToVoxelCenter(sc.x, sc.y, sc.sliceIndex, axis);
|
|
75695
|
+
const radii = this.getVoxelRadii(radius, axis);
|
|
75696
|
+
const bb = this.getBoundingBox(center, radii, dims);
|
|
75697
|
+
if (this.mode === "brush") {
|
|
75698
|
+
this.write3DSphereToBrush(vol, center, radii, bb, channel);
|
|
75699
|
+
}
|
|
75700
|
+
else {
|
|
75701
|
+
this.erase3DSphereFromVolume(vol, center, radii, bb, channel);
|
|
75702
|
+
}
|
|
75703
|
+
}
|
|
75704
|
+
this.refreshDisplay(layer, undefined, false);
|
|
75705
|
+
}
|
|
75191
75706
|
// ── Sphere Brush Click/PointerUp ───────────────────────────────
|
|
75192
75707
|
/**
|
|
75193
|
-
* Handle pointer-down in sphereBrush mode (
|
|
75708
|
+
* Handle pointer-down in sphereBrush mode (3D Slicer-style).
|
|
75709
|
+
*
|
|
75710
|
+
* Captures pristine snapshots at the mousedown location but does NOT
|
|
75711
|
+
* write any voxels yet — the user may wheel-adjust the radius before
|
|
75712
|
+
* dragging. The first sphere is written either by the first pointermove
|
|
75713
|
+
* or by pointerup (single-click fallback).
|
|
75194
75714
|
*/
|
|
75195
75715
|
onSphereBrushClick(e) {
|
|
75196
|
-
this.centerX = e.offsetX
|
|
75197
|
-
this.centerY = e.offsetY
|
|
75716
|
+
this.centerX = e.offsetX;
|
|
75717
|
+
this.centerY = e.offsetY;
|
|
75198
75718
|
this.centerSlice = this.ctx.nrrd_states.view.currentSliceIndex;
|
|
75719
|
+
this.lastDisplayX = this.centerX;
|
|
75720
|
+
this.lastDisplayY = this.centerY;
|
|
75721
|
+
this.lastSliceIndex = this.centerSlice;
|
|
75199
75722
|
this.active = true;
|
|
75200
75723
|
this.mode = "brush";
|
|
75201
|
-
this.
|
|
75724
|
+
this.dragMoved = false;
|
|
75725
|
+
this.dragBeforeSnapshots.clear();
|
|
75726
|
+
this.strokeCenters = [{ x: this.centerX, y: this.centerY, sliceIndex: this.centerSlice }];
|
|
75727
|
+
const layer = this.ctx.gui_states.layerChannel.layer;
|
|
75728
|
+
const axis = this.ctx.protectedData.axis;
|
|
75729
|
+
const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
|
|
75730
|
+
try {
|
|
75731
|
+
const vol = this.callbacks.getVolumeForLayer(layer);
|
|
75732
|
+
const dims = vol.getDimensions();
|
|
75733
|
+
const center = this.canvasToVoxelCenter(this.centerX, this.centerY, this.centerSlice, axis);
|
|
75734
|
+
const radii = this.getVoxelRadii(radius, axis);
|
|
75735
|
+
const bb = this.getBoundingBox(center, radii, dims);
|
|
75736
|
+
this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
|
|
75737
|
+
}
|
|
75738
|
+
catch (_a) {
|
|
75739
|
+
// Volume not ready — preview still shown so user gets visual feedback
|
|
75740
|
+
}
|
|
75741
|
+
this.drawPreview(this.centerX, this.centerY, radius, false);
|
|
75202
75742
|
}
|
|
75203
75743
|
/**
|
|
75204
|
-
* Handle pointer-
|
|
75744
|
+
* Handle pointer-move in sphereBrush mode — drag to continuously paint.
|
|
75745
|
+
*
|
|
75746
|
+
* Mirrors sphereEraser drag: writes a new sphere at each move position,
|
|
75747
|
+
* extends the cumulative before-snapshot to cover any newly-touched Z
|
|
75748
|
+
* slices, and repaints the preview. Never notifies the backend; pointerup
|
|
75749
|
+
* does that once for the whole stroke.
|
|
75205
75750
|
*/
|
|
75206
|
-
|
|
75751
|
+
onSphereBrushMove(e) {
|
|
75207
75752
|
if (!this.active || this.mode !== "brush")
|
|
75208
75753
|
return;
|
|
75209
|
-
this.
|
|
75754
|
+
this.dragMoved = true;
|
|
75755
|
+
const sliceIndex = this.ctx.nrrd_states.view.currentSliceIndex;
|
|
75756
|
+
this.lastDisplayX = e.offsetX;
|
|
75757
|
+
this.lastDisplayY = e.offsetY;
|
|
75758
|
+
this.lastSliceIndex = sliceIndex;
|
|
75759
|
+
this.strokeCenters.push({ x: e.offsetX, y: e.offsetY, sliceIndex });
|
|
75210
75760
|
const layer = this.ctx.gui_states.layerChannel.layer;
|
|
75211
75761
|
const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
|
|
75212
75762
|
const axis = this.ctx.protectedData.axis;
|
|
@@ -75216,26 +75766,56 @@ class SphereBrushTool extends BaseTool {
|
|
|
75216
75766
|
vol = this.callbacks.getVolumeForLayer(layer);
|
|
75217
75767
|
}
|
|
75218
75768
|
catch (_a) {
|
|
75219
|
-
this.clearPreview();
|
|
75220
75769
|
return;
|
|
75221
75770
|
}
|
|
75222
75771
|
const dims = vol.getDimensions();
|
|
75223
|
-
const center = this.canvasToVoxelCenter(
|
|
75224
|
-
const radii = this.getVoxelRadii(radius);
|
|
75772
|
+
const center = this.canvasToVoxelCenter(e.offsetX, e.offsetY, sliceIndex, axis);
|
|
75773
|
+
const radii = this.getVoxelRadii(radius, axis);
|
|
75225
75774
|
const bb = this.getBoundingBox(center, radii, dims);
|
|
75226
|
-
|
|
75227
|
-
const before = this.captureSliceSnapshots(vol, bb.minZ, bb.maxZ);
|
|
75228
|
-
// Write sphere
|
|
75775
|
+
this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
|
|
75229
75776
|
this.write3DSphereToBrush(vol, center, radii, bb, channel);
|
|
75230
|
-
|
|
75231
|
-
|
|
75232
|
-
|
|
75777
|
+
this.drawPreview(e.offsetX, e.offsetY, radius, false);
|
|
75778
|
+
this.refreshDisplay(layer, undefined, false);
|
|
75779
|
+
}
|
|
75780
|
+
/**
|
|
75781
|
+
* Handle pointer-up in sphereBrush mode — finalize stroke + push undo group
|
|
75782
|
+
* + single batched backend notify for all Z slices touched during the drag.
|
|
75783
|
+
*/
|
|
75784
|
+
onSphereBrushPointerUp() {
|
|
75785
|
+
if (!this.active || this.mode !== "brush")
|
|
75786
|
+
return;
|
|
75787
|
+
this.active = false;
|
|
75788
|
+
const layer = this.ctx.gui_states.layerChannel.layer;
|
|
75789
|
+
const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
|
|
75790
|
+
const axis = this.ctx.protectedData.axis;
|
|
75791
|
+
const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
|
|
75792
|
+
let vol;
|
|
75793
|
+
try {
|
|
75794
|
+
vol = this.callbacks.getVolumeForLayer(layer);
|
|
75795
|
+
}
|
|
75796
|
+
catch (_a) {
|
|
75797
|
+
this.finalizeStrokeState();
|
|
75798
|
+
return;
|
|
75799
|
+
}
|
|
75800
|
+
// Click-without-drag: write the single sphere now so a plain click still
|
|
75801
|
+
// paints something. onSphereBrushClick intentionally skipped this so the
|
|
75802
|
+
// user could wheel-adjust radius first.
|
|
75803
|
+
if (!this.dragMoved && this.strokeCenters.length > 0) {
|
|
75804
|
+
const sc = this.strokeCenters[0];
|
|
75805
|
+
const dims = vol.getDimensions();
|
|
75806
|
+
const center = this.canvasToVoxelCenter(sc.x, sc.y, sc.sliceIndex, axis);
|
|
75807
|
+
const radii = this.getVoxelRadii(radius, axis);
|
|
75808
|
+
const bb = this.getBoundingBox(center, radii, dims);
|
|
75809
|
+
this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
|
|
75810
|
+
this.write3DSphereToBrush(vol, center, radii, bb, channel);
|
|
75811
|
+
}
|
|
75812
|
+
const after = this.captureSliceSnapshots(vol, this.dragMinZ(), this.dragMaxZ());
|
|
75813
|
+
const deltas = this.buildUndoGroup(layer, this.dragBeforeSnapshots, after);
|
|
75233
75814
|
if (deltas.length > 0) {
|
|
75234
75815
|
this.callbacks.pushUndoGroup(deltas);
|
|
75235
75816
|
}
|
|
75236
|
-
|
|
75237
|
-
this.
|
|
75238
|
-
this.clearPreview();
|
|
75817
|
+
this.refreshDisplay(layer, deltas, true);
|
|
75818
|
+
this.finalizeStrokeState();
|
|
75239
75819
|
}
|
|
75240
75820
|
// ── Sphere Eraser Click/PointerUp ──────────────────────────────
|
|
75241
75821
|
/**
|
|
@@ -75243,13 +75823,17 @@ class SphereBrushTool extends BaseTool {
|
|
|
75243
75823
|
* Initializes drag tracking for cumulative undo.
|
|
75244
75824
|
*/
|
|
75245
75825
|
onSphereEraserClick(e) {
|
|
75246
|
-
this.centerX = e.offsetX
|
|
75247
|
-
this.centerY = e.offsetY
|
|
75826
|
+
this.centerX = e.offsetX;
|
|
75827
|
+
this.centerY = e.offsetY;
|
|
75248
75828
|
this.centerSlice = this.ctx.nrrd_states.view.currentSliceIndex;
|
|
75829
|
+
this.lastDisplayX = this.centerX;
|
|
75830
|
+
this.lastDisplayY = this.centerY;
|
|
75831
|
+
this.lastSliceIndex = this.centerSlice;
|
|
75249
75832
|
this.active = true;
|
|
75250
75833
|
this.mode = "eraser";
|
|
75251
75834
|
this.dragMoved = false;
|
|
75252
75835
|
this.dragBeforeSnapshots.clear();
|
|
75836
|
+
this.strokeCenters = [{ x: this.centerX, y: this.centerY, sliceIndex: this.centerSlice }];
|
|
75253
75837
|
// Capture initial "before" snapshots for the first sphere position
|
|
75254
75838
|
const layer = this.ctx.gui_states.layerChannel.layer;
|
|
75255
75839
|
const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
|
|
@@ -75258,7 +75842,7 @@ class SphereBrushTool extends BaseTool {
|
|
|
75258
75842
|
const dims = vol.getDimensions();
|
|
75259
75843
|
const axis = this.ctx.protectedData.axis;
|
|
75260
75844
|
const center = this.canvasToVoxelCenter(this.centerX, this.centerY, this.centerSlice, axis);
|
|
75261
|
-
const radii = this.getVoxelRadii(radius);
|
|
75845
|
+
const radii = this.getVoxelRadii(radius, axis);
|
|
75262
75846
|
const bb = this.getBoundingBox(center, radii, dims);
|
|
75263
75847
|
this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
|
|
75264
75848
|
}
|
|
@@ -75274,9 +75858,11 @@ class SphereBrushTool extends BaseTool {
|
|
|
75274
75858
|
if (!this.active || this.mode !== "eraser")
|
|
75275
75859
|
return;
|
|
75276
75860
|
this.dragMoved = true;
|
|
75277
|
-
const mouseX = e.offsetX / this.ctx.nrrd_states.view.sizeFactor;
|
|
75278
|
-
const mouseY = e.offsetY / this.ctx.nrrd_states.view.sizeFactor;
|
|
75279
75861
|
const sliceIndex = this.ctx.nrrd_states.view.currentSliceIndex;
|
|
75862
|
+
this.lastDisplayX = e.offsetX;
|
|
75863
|
+
this.lastDisplayY = e.offsetY;
|
|
75864
|
+
this.lastSliceIndex = sliceIndex;
|
|
75865
|
+
this.strokeCenters.push({ x: e.offsetX, y: e.offsetY, sliceIndex });
|
|
75280
75866
|
const layer = this.ctx.gui_states.layerChannel.layer;
|
|
75281
75867
|
const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
|
|
75282
75868
|
const axis = this.ctx.protectedData.axis;
|
|
@@ -75289,17 +75875,18 @@ class SphereBrushTool extends BaseTool {
|
|
|
75289
75875
|
return;
|
|
75290
75876
|
}
|
|
75291
75877
|
const dims = vol.getDimensions();
|
|
75292
|
-
const center = this.canvasToVoxelCenter(
|
|
75293
|
-
const radii = this.getVoxelRadii(radius);
|
|
75878
|
+
const center = this.canvasToVoxelCenter(e.offsetX, e.offsetY, sliceIndex, axis);
|
|
75879
|
+
const radii = this.getVoxelRadii(radius, axis);
|
|
75294
75880
|
const bb = this.getBoundingBox(center, radii, dims);
|
|
75295
75881
|
// Expand "before" snapshots to cover any new Z slices
|
|
75296
75882
|
this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
|
|
75297
75883
|
// Erase sphere at current position
|
|
75298
75884
|
this.erase3DSphereFromVolume(vol, center, radii, bb, channel);
|
|
75299
|
-
// Update preview position
|
|
75300
|
-
this.drawPreview(
|
|
75301
|
-
// Refresh display
|
|
75302
|
-
|
|
75885
|
+
// Update preview position (pass display/zoomed coords)
|
|
75886
|
+
this.drawPreview(e.offsetX, e.offsetY, radius, true);
|
|
75887
|
+
// Refresh display locally — skip backend notify to avoid per-frame HTTP.
|
|
75888
|
+
// onSphereEraserPointerUp batches all deltas into a single notify.
|
|
75889
|
+
this.refreshDisplay(layer, undefined, false);
|
|
75303
75890
|
}
|
|
75304
75891
|
/**
|
|
75305
75892
|
* Handle pointer-up in sphereEraser mode — finalize erase + push undo group.
|
|
@@ -75318,16 +75905,17 @@ class SphereBrushTool extends BaseTool {
|
|
|
75318
75905
|
vol = this.callbacks.getVolumeForLayer(layer);
|
|
75319
75906
|
}
|
|
75320
75907
|
catch (_a) {
|
|
75321
|
-
this.
|
|
75322
|
-
this.dragBeforeSnapshots.clear();
|
|
75908
|
+
this.finalizeStrokeState();
|
|
75323
75909
|
return;
|
|
75324
75910
|
}
|
|
75325
|
-
//
|
|
75326
|
-
if (!this.dragMoved) {
|
|
75911
|
+
// Click-without-drag: erase at the click position (click-release behavior).
|
|
75912
|
+
if (!this.dragMoved && this.strokeCenters.length > 0) {
|
|
75913
|
+
const sc = this.strokeCenters[0];
|
|
75327
75914
|
const dims = vol.getDimensions();
|
|
75328
|
-
const center = this.canvasToVoxelCenter(
|
|
75329
|
-
const radii = this.getVoxelRadii(radius);
|
|
75915
|
+
const center = this.canvasToVoxelCenter(sc.x, sc.y, sc.sliceIndex, axis);
|
|
75916
|
+
const radii = this.getVoxelRadii(radius, axis);
|
|
75330
75917
|
const bb = this.getBoundingBox(center, radii, dims);
|
|
75918
|
+
this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
|
|
75331
75919
|
this.erase3DSphereFromVolume(vol, center, radii, bb, channel);
|
|
75332
75920
|
}
|
|
75333
75921
|
// Build undo group from cumulative "before" snapshots vs current state
|
|
@@ -75336,10 +75924,20 @@ class SphereBrushTool extends BaseTool {
|
|
|
75336
75924
|
if (deltas.length > 0) {
|
|
75337
75925
|
this.callbacks.pushUndoGroup(deltas);
|
|
75338
75926
|
}
|
|
75339
|
-
// Refresh display and notify backend of ALL changed slices
|
|
75340
|
-
this.refreshDisplay(layer, deltas);
|
|
75927
|
+
// Refresh display and notify backend of ALL changed slices — single HTTP batch
|
|
75928
|
+
this.refreshDisplay(layer, deltas, true);
|
|
75929
|
+
this.finalizeStrokeState();
|
|
75930
|
+
}
|
|
75931
|
+
/**
|
|
75932
|
+
* Clear per-stroke state after pointerup or cancel. Kept as a helper so
|
|
75933
|
+
* brush/eraser pointerup and cancelActivePlacement all reset the same set
|
|
75934
|
+
* of fields — if a new drag-state field is added, only one place to update.
|
|
75935
|
+
*/
|
|
75936
|
+
finalizeStrokeState() {
|
|
75341
75937
|
this.clearPreview();
|
|
75342
75938
|
this.dragBeforeSnapshots.clear();
|
|
75939
|
+
this.strokeCenters = [];
|
|
75940
|
+
this.dragMoved = false;
|
|
75343
75941
|
}
|
|
75344
75942
|
/**
|
|
75345
75943
|
* Expand cumulative "before" snapshots to cover z range [minZ, maxZ].
|
|
@@ -75384,8 +75982,11 @@ class SphereBrushTool extends BaseTool {
|
|
|
75384
75982
|
* @param changedDeltas - If provided, fire onMaskChanged for ALL changed
|
|
75385
75983
|
* slices (not just the current view slice) so the backend receives the
|
|
75386
75984
|
* full 3D sphere data for NII/GLTF export.
|
|
75985
|
+
* @param notifyBackend - When false, only refresh the local canvas and skip
|
|
75986
|
+
* the onMaskChanged callback. Used during drag to avoid per-frame HTTP;
|
|
75987
|
+
* pointerup then calls with `true` to send a single batched update.
|
|
75387
75988
|
*/
|
|
75388
|
-
refreshDisplay(layerId, changedDeltas) {
|
|
75989
|
+
refreshDisplay(layerId, changedDeltas, notifyBackend = true) {
|
|
75389
75990
|
// Re-render layer canvas from volume for the current slice
|
|
75390
75991
|
const target = this.ctx.protectedData.layerTargets.get(layerId);
|
|
75391
75992
|
if (target) {
|
|
@@ -75398,6 +75999,9 @@ class SphereBrushTool extends BaseTool {
|
|
|
75398
75999
|
}
|
|
75399
76000
|
// Composite all layers to master canvas
|
|
75400
76001
|
this.callbacks.compositeAllLayers();
|
|
76002
|
+
// Skip backend notify during drag — pointerup batches a single notify for the whole stroke.
|
|
76003
|
+
if (!notifyBackend)
|
|
76004
|
+
return;
|
|
75401
76005
|
// Fire onMaskChanged for ALL changed slices (not just the current view slice)
|
|
75402
76006
|
// This ensures the backend receives the full 3D sphere data for export.
|
|
75403
76007
|
try {
|
|
@@ -75426,6 +76030,23 @@ class SphereBrushTool extends BaseTool {
|
|
|
75426
76030
|
get isActive() {
|
|
75427
76031
|
return this.active;
|
|
75428
76032
|
}
|
|
76033
|
+
/**
|
|
76034
|
+
* Defensively cancel any in-progress sphere placement and clear the preview.
|
|
76035
|
+
*
|
|
76036
|
+
* Called when crosshair mode is entered so any lingering sphere preview
|
|
76037
|
+
* (e.g. red dashed eraser outline) is wiped immediately. In normal flow
|
|
76038
|
+
* `leftButtonDown === true` blocks crosshair toggle, so this shouldn't
|
|
76039
|
+
* fire mid-stroke — but if it ever does, we drop the drag snapshot
|
|
76040
|
+
* rather than committing a partial undo entry.
|
|
76041
|
+
*/
|
|
76042
|
+
cancelActivePlacement() {
|
|
76043
|
+
if (!this.active) {
|
|
76044
|
+
this.clearPreview();
|
|
76045
|
+
return;
|
|
76046
|
+
}
|
|
76047
|
+
this.active = false;
|
|
76048
|
+
this.finalizeStrokeState();
|
|
76049
|
+
}
|
|
75429
76050
|
}
|
|
75430
76051
|
|
|
75431
76052
|
/**
|
|
@@ -75530,6 +76151,8 @@ class DrawToolCore {
|
|
|
75530
76151
|
getVolumeForLayer: (layer) => this.renderer.getVolumeForLayer(layer),
|
|
75531
76152
|
pushUndoDelta: (delta) => this.undoManager.push(delta),
|
|
75532
76153
|
getEraserUrls: () => this.eraserUrls,
|
|
76154
|
+
renderSliceToCanvas: (layer, axis, sliceIndex, buffer, ctx, w, h) => this.renderer.renderSliceToCanvas(layer, axis, sliceIndex, buffer, ctx, w, h),
|
|
76155
|
+
getOrCreateSliceBuffer: (axis) => this.renderer.getOrCreateSliceBuffer(axis),
|
|
75533
76156
|
});
|
|
75534
76157
|
this.sphereBrushTool = new SphereBrushTool(toolCtx, {
|
|
75535
76158
|
getVolumeForLayer: (layer) => this.renderer.getVolumeForLayer(layer),
|
|
@@ -75548,6 +76171,7 @@ class DrawToolCore {
|
|
|
75548
76171
|
container: this.container,
|
|
75549
76172
|
canvas: this.state.protectedData.canvases.drawingCanvas,
|
|
75550
76173
|
onModeChange: (prevMode, newMode) => {
|
|
76174
|
+
var _a;
|
|
75551
76175
|
// Use string comparison to avoid TypeScript narrowing issues
|
|
75552
76176
|
const prev = prevMode;
|
|
75553
76177
|
const next = newMode;
|
|
@@ -75560,6 +76184,10 @@ class DrawToolCore {
|
|
|
75560
76184
|
}
|
|
75561
76185
|
if (next === 'crosshair') {
|
|
75562
76186
|
this.state.protectedData.isDrawing = false;
|
|
76187
|
+
// Wipe any lingering sphere preview (red dashed eraser outline /
|
|
76188
|
+
// filled brush preview) when the user toggles into crosshair from
|
|
76189
|
+
// sphereBrush/sphereEraser. Safe no-op when no placement is active.
|
|
76190
|
+
(_a = this.sphereBrushTool) === null || _a === void 0 ? void 0 : _a.cancelActivePlacement();
|
|
75563
76191
|
}
|
|
75564
76192
|
}
|
|
75565
76193
|
});
|
|
@@ -75658,9 +76286,14 @@ class DrawToolCore {
|
|
|
75658
76286
|
if (this.drawingTool.isActive || this.panTool.isActive) {
|
|
75659
76287
|
this.drawingPrameters.handleOnDrawingMouseMove(e);
|
|
75660
76288
|
}
|
|
75661
|
-
// Drag
|
|
75662
|
-
if (this.sphereBrushTool.isActive
|
|
75663
|
-
this.
|
|
76289
|
+
// Drag: route move to sphereBrushTool for both brush and eraser
|
|
76290
|
+
if (this.sphereBrushTool.isActive) {
|
|
76291
|
+
if (this.state.gui_states.mode.sphereEraser) {
|
|
76292
|
+
this.sphereBrushTool.onSphereEraserMove(e);
|
|
76293
|
+
}
|
|
76294
|
+
else if (this.state.gui_states.mode.sphereBrush) {
|
|
76295
|
+
this.sphereBrushTool.onSphereBrushMove(e);
|
|
76296
|
+
}
|
|
75664
76297
|
}
|
|
75665
76298
|
});
|
|
75666
76299
|
this.eventRouter.setPointerUpHandler((e) => {
|
|
@@ -75959,6 +76592,11 @@ class DrawToolCore {
|
|
|
75959
76592
|
const ctx = this.state.protectedData.ctxes.emptyCtx;
|
|
75960
76593
|
const w = this.state.protectedData.canvases.emptyCanvas.width;
|
|
75961
76594
|
const h = this.state.protectedData.canvases.emptyCanvas.height;
|
|
76595
|
+
// Bake step: downscale layer canvas (display resolution) to emptyCanvas
|
|
76596
|
+
// (voxel resolution) before label extraction. Use nearest-neighbor
|
|
76597
|
+
// (smoothing=false) so voxel occupancy is determined directly by the
|
|
76598
|
+
// drawn pixels on the layer canvas, without blending that could shift
|
|
76599
|
+
// the alpha>=128 threshold decision in setSliceLabelsFromImageData.
|
|
75962
76600
|
ctx.imageSmoothingEnabled = false;
|
|
75963
76601
|
ctx.drawImage(canvas, 0, 0, w, h);
|
|
75964
76602
|
}
|
|
@@ -77665,11 +78303,15 @@ class NrrdTools {
|
|
|
77665
78303
|
this.state.gui_states.mode.sphereBrush = true;
|
|
77666
78304
|
this.dragOperator.removeDragMode();
|
|
77667
78305
|
(_a = this.drawCore.eventRouter) === null || _a === void 0 ? void 0 : _a.setGuiTool('sphereBrush');
|
|
78306
|
+
this.state.protectedData.canvases.drawingCanvas.style.cursor =
|
|
78307
|
+
this.state.gui_states.viewConfig.defaultPaintCursor;
|
|
77668
78308
|
break;
|
|
77669
78309
|
case "sphereEraser":
|
|
77670
78310
|
this.state.gui_states.mode.sphereEraser = true;
|
|
77671
78311
|
this.dragOperator.removeDragMode();
|
|
77672
78312
|
(_b = this.drawCore.eventRouter) === null || _b === void 0 ? void 0 : _b.setGuiTool('sphereEraser');
|
|
78313
|
+
this.state.protectedData.canvases.drawingCanvas.style.cursor =
|
|
78314
|
+
this.state.gui_states.viewConfig.defaultPaintCursor;
|
|
77673
78315
|
break;
|
|
77674
78316
|
}
|
|
77675
78317
|
// Restore drag mode when leaving sphereBrush/sphereEraser
|
|
@@ -78397,6 +79039,42 @@ class NrrdTools {
|
|
|
78397
79039
|
drawCalculatorSphereOnEachViews(axis) {
|
|
78398
79040
|
this.drawCore.drawCalculatorSphereOnEachViews(axis);
|
|
78399
79041
|
}
|
|
79042
|
+
/**
|
|
79043
|
+
* Copy all voxel data from one layer's MaskVolume to another.
|
|
79044
|
+
*
|
|
79045
|
+
* After the copy the target layer's display is refreshed so the UI
|
|
79046
|
+
* shows the updated data immediately. The target layer's color map
|
|
79047
|
+
* is preserved — only the raw voxel buffer is overwritten.
|
|
79048
|
+
*
|
|
79049
|
+
* Use case: when editing layer2 cascades to layer3 on the backend,
|
|
79050
|
+
* call this to keep the frontend in sync without a network round-trip.
|
|
79051
|
+
*
|
|
79052
|
+
* @param sourceLayerId Layer to copy from (e.g. "layer2").
|
|
79053
|
+
* @param targetLayerId Layer to copy into (e.g. "layer3").
|
|
79054
|
+
*
|
|
79055
|
+
* @example
|
|
79056
|
+
* ```ts
|
|
79057
|
+
* nrrdTools.copyLayerData("layer2", "layer3");
|
|
79058
|
+
* ```
|
|
79059
|
+
*/
|
|
79060
|
+
copyLayerData(sourceLayerId, targetLayerId) {
|
|
79061
|
+
const srcVol = this.drawCore.renderer.getVolumeForLayer(sourceLayerId);
|
|
79062
|
+
const dstVol = this.drawCore.renderer.getVolumeForLayer(targetLayerId);
|
|
79063
|
+
if (!srcVol || !dstVol) {
|
|
79064
|
+
console.warn(`copyLayerData: cannot resolve volumes for "${sourceLayerId}" → "${targetLayerId}"`);
|
|
79065
|
+
return;
|
|
79066
|
+
}
|
|
79067
|
+
const srcData = srcVol.getRawData();
|
|
79068
|
+
const dstData = dstVol.getRawData();
|
|
79069
|
+
if (srcData.length !== dstData.length) {
|
|
79070
|
+
console.warn(`copyLayerData: size mismatch (${srcData.length} vs ${dstData.length})`);
|
|
79071
|
+
return;
|
|
79072
|
+
}
|
|
79073
|
+
// Copy voxel data (preserves target color map)
|
|
79074
|
+
dstVol.setRawData(new Uint8Array(srcData));
|
|
79075
|
+
// Refresh the display so all three axes reflect the new data
|
|
79076
|
+
this.reloadMasksFromVolume();
|
|
79077
|
+
}
|
|
78400
79078
|
}
|
|
78401
79079
|
|
|
78402
79080
|
class Node {
|
|
@@ -78606,7 +79284,7 @@ function evaluateElement(element, weights) {
|
|
|
78606
79284
|
}
|
|
78607
79285
|
|
|
78608
79286
|
// import * as kiwrious from "copper3d_plugin_heart_k";
|
|
78609
|
-
const REVISION = "v3.
|
|
79287
|
+
const REVISION = "v3.4.0-beta";
|
|
78610
79288
|
console.log(`%cCopper3D Visualisation %cBeta:${REVISION}`, "padding: 3px;color:white; background:#023047", "padding: 3px;color:white; background:#f50a25");
|
|
78611
79289
|
|
|
78612
79290
|
export { CHANNEL_COLORS, CHANNEL_HEX_COLORS, CameraViewPoint, Copper3dTrackballControls, GaussianSmoother, MeshNodeTool, NrrdTools, REVISION, addBoxHelper, addLabelToScene, configKiwriousHeart, convert3DPostoScreenPos, convertScreenPosto3DPos, copperMScene, copperMSceneRenderer, copperRenderer, copperRendererOnDemond, copperScene, copperSceneOnDemond, createTexture2D_NRRD, fullScreenListenner, kiwrious, loading, removeGuiFolderChilden, rgbaToCss, rgbaToHex, setHDRFilePath, throttle };
|