copper3d 3.1.2 → 3.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Utils/segmentation/DragOperator.d.ts +2 -1
- package/dist/Utils/segmentation/DragOperator.js +6 -2
- package/dist/Utils/segmentation/DragOperator.js.map +1 -1
- package/dist/Utils/segmentation/DrawToolCore.d.ts +6 -0
- package/dist/Utils/segmentation/DrawToolCore.js +107 -35
- package/dist/Utils/segmentation/DrawToolCore.js.map +1 -1
- package/dist/Utils/segmentation/NrrdTools.d.ts +5 -1
- package/dist/Utils/segmentation/NrrdTools.js +87 -3
- package/dist/Utils/segmentation/NrrdTools.js.map +1 -1
- package/dist/Utils/segmentation/core/GaussianSmoother.d.ts +55 -0
- package/dist/Utils/segmentation/core/GaussianSmoother.js +210 -0
- package/dist/Utils/segmentation/core/GaussianSmoother.js.map +1 -0
- package/dist/Utils/segmentation/core/MaskVolume.d.ts +9 -0
- package/dist/Utils/segmentation/core/MaskVolume.js +11 -0
- package/dist/Utils/segmentation/core/MaskVolume.js.map +1 -1
- package/dist/Utils/segmentation/core/UndoManager.d.ts +9 -5
- package/dist/Utils/segmentation/core/UndoManager.js +21 -14
- package/dist/Utils/segmentation/core/UndoManager.js.map +1 -1
- package/dist/Utils/segmentation/core/index.d.ts +1 -1
- package/dist/Utils/segmentation/core/types.d.ts +5 -1
- package/dist/Utils/segmentation/core/types.js.map +1 -1
- package/dist/Utils/segmentation/coreTools/GuiState.js +2 -0
- package/dist/Utils/segmentation/coreTools/GuiState.js.map +1 -1
- package/dist/Utils/segmentation/coreTools/NrrdState.js +2 -0
- package/dist/Utils/segmentation/coreTools/NrrdState.js.map +1 -1
- package/dist/Utils/segmentation/eventRouter/EventRouter.js +1 -0
- package/dist/Utils/segmentation/eventRouter/EventRouter.js.map +1 -1
- package/dist/Utils/segmentation/eventRouter/types.d.ts +1 -1
- package/dist/Utils/segmentation/tools/DragSliceTool.d.ts +0 -4
- package/dist/Utils/segmentation/tools/DragSliceTool.js +2 -19
- package/dist/Utils/segmentation/tools/DragSliceTool.js.map +1 -1
- package/dist/Utils/segmentation/tools/SliceRenderPipeline.js +8 -1
- package/dist/Utils/segmentation/tools/SliceRenderPipeline.js.map +1 -1
- package/dist/Utils/segmentation/tools/SphereBrushTool.d.ts +127 -0
- package/dist/Utils/segmentation/tools/SphereBrushTool.js +489 -0
- package/dist/Utils/segmentation/tools/SphereBrushTool.js.map +1 -0
- package/dist/Utils/segmentation/tools/ToolHost.d.ts +4 -1
- package/dist/Utils/segmentation/tools/ZoomTool.js +6 -1
- package/dist/Utils/segmentation/tools/ZoomTool.js.map +1 -1
- package/dist/Utils/segmentation/tools/index.d.ts +2 -1
- package/dist/Utils/segmentation/tools/index.js +1 -0
- package/dist/Utils/segmentation/tools/index.js.map +1 -1
- package/dist/bundle.esm.js +950 -77
- package/dist/bundle.umd.js +950 -76
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/types/Utils/segmentation/DragOperator.d.ts +2 -1
- package/dist/types/Utils/segmentation/DrawToolCore.d.ts +6 -0
- package/dist/types/Utils/segmentation/NrrdTools.d.ts +5 -1
- package/dist/types/Utils/segmentation/core/GaussianSmoother.d.ts +55 -0
- package/dist/types/Utils/segmentation/core/MaskVolume.d.ts +9 -0
- package/dist/types/Utils/segmentation/core/UndoManager.d.ts +9 -5
- package/dist/types/Utils/segmentation/core/index.d.ts +1 -1
- package/dist/types/Utils/segmentation/core/types.d.ts +5 -1
- package/dist/types/Utils/segmentation/eventRouter/types.d.ts +1 -1
- package/dist/types/Utils/segmentation/tools/DragSliceTool.d.ts +0 -4
- package/dist/types/Utils/segmentation/tools/SphereBrushTool.d.ts +127 -0
- package/dist/types/Utils/segmentation/tools/ToolHost.d.ts +4 -1
- package/dist/types/Utils/segmentation/tools/index.d.ts +2 -1
- package/dist/types/index.d.ts +3 -2
- package/package.json +1 -1
package/dist/bundle.esm.js
CHANGED
|
@@ -71387,6 +71387,17 @@ class MaskVolume {
|
|
|
71387
71387
|
getChannels() {
|
|
71388
71388
|
return this.numChannels;
|
|
71389
71389
|
}
|
|
71390
|
+
/**
|
|
71391
|
+
* Return the number of bytes in one complete z-slice.
|
|
71392
|
+
*
|
|
71393
|
+
* Equal to `width × height × channels`. Useful for direct buffer
|
|
71394
|
+
* index arithmetic in performance-critical code paths.
|
|
71395
|
+
*
|
|
71396
|
+
* @returns Bytes per z-slice.
|
|
71397
|
+
*/
|
|
71398
|
+
getBytesPerSlice() {
|
|
71399
|
+
return this.bytesPerSlice;
|
|
71400
|
+
}
|
|
71390
71401
|
/**
|
|
71391
71402
|
* Return total memory used by the backing buffer, in bytes.
|
|
71392
71403
|
*
|
|
@@ -71955,41 +71966,48 @@ class UndoManager {
|
|
|
71955
71966
|
setActiveLayer(layer) {
|
|
71956
71967
|
this.activeLayer = layer;
|
|
71957
71968
|
}
|
|
71958
|
-
/** Push a delta onto the active layer's undo stack and clear the redo stack. */
|
|
71969
|
+
/** Push a single delta onto the active layer's undo stack and clear the redo stack. */
|
|
71959
71970
|
push(delta) {
|
|
71971
|
+
this.pushGroup([delta]);
|
|
71972
|
+
}
|
|
71973
|
+
/** Push a group of deltas as a single undo unit (e.g. 3D sphere spanning multiple slices). */
|
|
71974
|
+
pushGroup(deltas) {
|
|
71960
71975
|
var _a, _b;
|
|
71961
|
-
|
|
71962
|
-
|
|
71976
|
+
if (deltas.length === 0)
|
|
71977
|
+
return;
|
|
71978
|
+
const layerId = deltas[0].layerId;
|
|
71979
|
+
const stack = (_a = this.undoStacks.get(layerId)) !== null && _a !== void 0 ? _a : this.undoStacks.get("layer1");
|
|
71980
|
+
stack.push(deltas);
|
|
71963
71981
|
if (stack.length > MAX_STACK_SIZE) {
|
|
71964
71982
|
stack.shift();
|
|
71965
71983
|
}
|
|
71966
71984
|
// Any new operation invalidates the redo history for that layer
|
|
71967
|
-
const redoStack = (_b = this.redoStacks.get(
|
|
71985
|
+
const redoStack = (_b = this.redoStacks.get(layerId)) !== null && _b !== void 0 ? _b : this.redoStacks.get("layer1");
|
|
71968
71986
|
redoStack.length = 0;
|
|
71969
71987
|
}
|
|
71970
71988
|
/**
|
|
71971
71989
|
* Undo the last operation on the active layer.
|
|
71972
|
-
* @returns The delta that
|
|
71990
|
+
* @returns The delta(s) that were undone, or undefined if nothing to undo.
|
|
71973
71991
|
*/
|
|
71974
71992
|
undo() {
|
|
71975
71993
|
const stack = this.undoStacks.get(this.activeLayer);
|
|
71976
|
-
const
|
|
71977
|
-
if (
|
|
71978
|
-
this.redoStacks.get(this.activeLayer).push(
|
|
71994
|
+
const entry = stack.pop();
|
|
71995
|
+
if (entry) {
|
|
71996
|
+
this.redoStacks.get(this.activeLayer).push(entry);
|
|
71979
71997
|
}
|
|
71980
|
-
return
|
|
71998
|
+
return entry;
|
|
71981
71999
|
}
|
|
71982
72000
|
/**
|
|
71983
72001
|
* Redo the last undone operation on the active layer.
|
|
71984
|
-
* @returns The delta that
|
|
72002
|
+
* @returns The delta(s) that were redone, or undefined if nothing to redo.
|
|
71985
72003
|
*/
|
|
71986
72004
|
redo() {
|
|
71987
72005
|
const stack = this.redoStacks.get(this.activeLayer);
|
|
71988
|
-
const
|
|
71989
|
-
if (
|
|
71990
|
-
this.undoStacks.get(this.activeLayer).push(
|
|
72006
|
+
const entry = stack.pop();
|
|
72007
|
+
if (entry) {
|
|
72008
|
+
this.undoStacks.get(this.activeLayer).push(entry);
|
|
71991
72009
|
}
|
|
71992
|
-
return
|
|
72010
|
+
return entry;
|
|
71993
72011
|
}
|
|
71994
72012
|
canUndo() {
|
|
71995
72013
|
var _a, _b;
|
|
@@ -73197,8 +73215,8 @@ class DragSliceTool extends BaseTool {
|
|
|
73197
73215
|
}
|
|
73198
73216
|
}
|
|
73199
73217
|
}
|
|
73200
|
-
// Composite all layers to master canvas
|
|
73201
|
-
this.compositeAllLayers();
|
|
73218
|
+
// Composite all layers to master canvas (uses per-layer alpha)
|
|
73219
|
+
this.callbacks.compositeAllLayers();
|
|
73202
73220
|
}
|
|
73203
73221
|
// Refresh sphere overlay from volume for the new slice
|
|
73204
73222
|
if (this.ctx.gui_states.mode.sphere) {
|
|
@@ -73207,23 +73225,6 @@ class DragSliceTool extends BaseTool {
|
|
|
73207
73225
|
view.switchSliceFlag = false;
|
|
73208
73226
|
}
|
|
73209
73227
|
}
|
|
73210
|
-
/**
|
|
73211
|
-
* Composite all visible layer canvases to the master display canvas.
|
|
73212
|
-
*/
|
|
73213
|
-
compositeAllLayers() {
|
|
73214
|
-
const masterCtx = this.ctx.protectedData.ctxes.drawingLayerMasterCtx;
|
|
73215
|
-
const width = this.ctx.nrrd_states.view.changedWidth;
|
|
73216
|
-
const height = this.ctx.nrrd_states.view.changedHeight;
|
|
73217
|
-
masterCtx.clearRect(0, 0, width, height);
|
|
73218
|
-
// Master stores full-alpha composite; globalAlpha applied in start() render loop.
|
|
73219
|
-
for (const layerId of this.ctx.nrrd_states.image.layers) {
|
|
73220
|
-
if (!this.ctx.gui_states.layerChannel.layerVisibility[layerId])
|
|
73221
|
-
continue;
|
|
73222
|
-
const target = this.ctx.protectedData.layerTargets.get(layerId);
|
|
73223
|
-
if (target)
|
|
73224
|
-
masterCtx.drawImage(target.canvas, 0, 0, width, height);
|
|
73225
|
-
}
|
|
73226
|
-
}
|
|
73227
73228
|
// ===== Canvas Cleanup =====
|
|
73228
73229
|
cleanCanvases(flag) {
|
|
73229
73230
|
if (flag) {
|
|
@@ -73265,7 +73266,7 @@ class DragSliceTool extends BaseTool {
|
|
|
73265
73266
|
}
|
|
73266
73267
|
|
|
73267
73268
|
class DragOperator {
|
|
73268
|
-
constructor(container, nrrd_sates, gui_states, protectedData, drawingPrameters, setSyncsliceNum, setIsDrawFalse, flipDisplayImageByAxis, setEmptyCanvasSize, getOrCreateSliceBuffer, renderSliceToCanvas) {
|
|
73269
|
+
constructor(container, nrrd_sates, gui_states, protectedData, drawingPrameters, setSyncsliceNum, setIsDrawFalse, flipDisplayImageByAxis, setEmptyCanvasSize, getOrCreateSliceBuffer, renderSliceToCanvas, compositeAllLayers) {
|
|
73269
73270
|
this.dragPrameters = {
|
|
73270
73271
|
move: 0,
|
|
73271
73272
|
y: 0,
|
|
@@ -73301,6 +73302,7 @@ class DragOperator {
|
|
|
73301
73302
|
this.setEmptyCanvasSize = setEmptyCanvasSize;
|
|
73302
73303
|
this.getOrCreateSliceBuffer = getOrCreateSliceBuffer;
|
|
73303
73304
|
this.renderSliceToCanvas = renderSliceToCanvas;
|
|
73305
|
+
this.compositeAllLayers = compositeAllLayers;
|
|
73304
73306
|
this.showDragNumberDiv = createShowSliceNumberDiv();
|
|
73305
73307
|
this.init();
|
|
73306
73308
|
}
|
|
@@ -73326,6 +73328,7 @@ class DragOperator {
|
|
|
73326
73328
|
setEmptyCanvasSize: (axis) => this.setEmptyCanvasSize(axis),
|
|
73327
73329
|
getOrCreateSliceBuffer: (axis) => this.getOrCreateSliceBuffer(axis),
|
|
73328
73330
|
renderSliceToCanvas: (layer, axis, sliceIndex, buffer, targetCtx, w, h) => this.renderSliceToCanvas(layer, axis, sliceIndex, buffer, targetCtx, w, h),
|
|
73331
|
+
compositeAllLayers: () => this.compositeAllLayers(),
|
|
73329
73332
|
refreshSphereOverlay: () => { var _a; return (_a = this.refreshSphereOverlayCb) === null || _a === void 0 ? void 0 : _a.call(this); },
|
|
73330
73333
|
}, this.showDragNumberDiv, dragEffectCanvases);
|
|
73331
73334
|
}
|
|
@@ -73358,7 +73361,9 @@ class DragOperator {
|
|
|
73358
73361
|
// When leaving draw, contrast, or crosshair mode (returning to idle), restore drag mode
|
|
73359
73362
|
// Do NOT restore if sphere mode is active
|
|
73360
73363
|
if ((prev === 'draw' || prev === 'contrast' || prev === 'crosshair') && next === 'idle') {
|
|
73361
|
-
if (!this.gui_states.mode.sphere
|
|
73364
|
+
if (!this.gui_states.mode.sphere
|
|
73365
|
+
&& !this.gui_states.mode.sphereBrush
|
|
73366
|
+
&& !this.gui_states.mode.sphereEraser) {
|
|
73362
73367
|
this.configDragMode();
|
|
73363
73368
|
}
|
|
73364
73369
|
}
|
|
@@ -73897,6 +73902,7 @@ class EventRouter {
|
|
|
73897
73902
|
if (ev.key === this.keyboardSettings.draw) {
|
|
73898
73903
|
this.state.shiftHeld = true;
|
|
73899
73904
|
// Block draw mode when crosshair or contrast is active (mutual exclusion)
|
|
73905
|
+
// Also block when sphereBrush or sphereEraser is active
|
|
73900
73906
|
if (DRAWING_TOOLS.has(this.guiTool) && !this.state.ctrlHeld && !this.state.crosshairEnabled) {
|
|
73901
73907
|
this.setMode('draw');
|
|
73902
73908
|
}
|
|
@@ -74296,10 +74302,15 @@ class ZoomTool extends BaseTool {
|
|
|
74296
74302
|
configMouseZoomWheel() {
|
|
74297
74303
|
let moveDistance = 1;
|
|
74298
74304
|
return (e) => {
|
|
74299
|
-
var _a;
|
|
74305
|
+
var _a, _b;
|
|
74300
74306
|
if ((_a = this.ctx.eventRouter) === null || _a === void 0 ? void 0 : _a.isShiftHeld()) {
|
|
74301
74307
|
return;
|
|
74302
74308
|
}
|
|
74309
|
+
// Block zoom wheel when sphereBrush/sphereEraser is actively placing (left button held)
|
|
74310
|
+
if ((this.ctx.gui_states.mode.sphereBrush || this.ctx.gui_states.mode.sphereEraser)
|
|
74311
|
+
&& ((_b = this.ctx.eventRouter) === null || _b === void 0 ? void 0 : _b.isLeftButtonDown())) {
|
|
74312
|
+
return;
|
|
74313
|
+
}
|
|
74303
74314
|
e.preventDefault();
|
|
74304
74315
|
const delta = e.detail ? e.detail > 0 : e.wheelDelta < 0;
|
|
74305
74316
|
this.ctx.protectedData.isDrawing = true;
|
|
@@ -74901,6 +74912,493 @@ class ImageStoreHelper extends BaseTool {
|
|
|
74901
74912
|
}
|
|
74902
74913
|
}
|
|
74903
74914
|
|
|
74915
|
+
/**
|
|
74916
|
+
* SphereBrushTool — 3D sphere brush and eraser on layer MaskVolume.
|
|
74917
|
+
*
|
|
74918
|
+
* Unlike SphereTool (which uses its own isolated sphereMaskVolume),
|
|
74919
|
+
* this tool writes/erases directly in the active layer's shared MaskVolume —
|
|
74920
|
+
* the same volume used by pencil/brush/eraser tools.
|
|
74921
|
+
*
|
|
74922
|
+
* Two operation modes handled by one class:
|
|
74923
|
+
* - **sphereBrush**: Direct click to paint a 3D sphere of the active channel label.
|
|
74924
|
+
* - **sphereEraser**: Shift+click to erase a 3D sphere (only voxels matching active channel).
|
|
74925
|
+
*
|
|
74926
|
+
* Interaction:
|
|
74927
|
+
* 1. Pointer-down records sphere center, switches wheel to radius mode.
|
|
74928
|
+
* 2. Scroll wheel adjusts sphereBrushRadius [1, 50].
|
|
74929
|
+
* 3. Pointer-up writes/erases sphere to volume, captures grouped undo, refreshes canvas.
|
|
74930
|
+
*/
|
|
74931
|
+
class SphereBrushTool extends BaseTool {
|
|
74932
|
+
constructor(ctx, callbacks) {
|
|
74933
|
+
super(ctx);
|
|
74934
|
+
/** Recorded sphere center in canvas mm-space */
|
|
74935
|
+
this.centerX = 0;
|
|
74936
|
+
this.centerY = 0;
|
|
74937
|
+
this.centerSlice = 0;
|
|
74938
|
+
/** Whether a sphere placement is in progress */
|
|
74939
|
+
this.active = false;
|
|
74940
|
+
/** Current operation mode for the active placement */
|
|
74941
|
+
this.mode = "brush";
|
|
74942
|
+
/** Cumulative "before" snapshots for drag-erase undo (z-index → slice data) */
|
|
74943
|
+
this.dragBeforeSnapshots = new Map();
|
|
74944
|
+
/** Whether a drag-erase has actually moved (vs. simple click-release) */
|
|
74945
|
+
this.dragMoved = false;
|
|
74946
|
+
this.callbacks = callbacks;
|
|
74947
|
+
}
|
|
74948
|
+
setCallbacks(callbacks) {
|
|
74949
|
+
this.callbacks = callbacks;
|
|
74950
|
+
}
|
|
74951
|
+
// ── Geometry (ported from SphereTool) ──────────────────────────
|
|
74952
|
+
/**
|
|
74953
|
+
* Convert canvas mm-space coordinates to 3D voxel coordinates.
|
|
74954
|
+
*/
|
|
74955
|
+
canvasToVoxelCenter(canvasX, canvasY, sliceIndex, axis) {
|
|
74956
|
+
const nrrd = this.ctx.nrrd_states;
|
|
74957
|
+
switch (axis) {
|
|
74958
|
+
case 'z':
|
|
74959
|
+
return {
|
|
74960
|
+
x: canvasX * nrrd.image.nrrd_x_pixel / nrrd.image.nrrd_x_mm,
|
|
74961
|
+
y: canvasY * nrrd.image.nrrd_y_pixel / nrrd.image.nrrd_y_mm,
|
|
74962
|
+
z: sliceIndex,
|
|
74963
|
+
};
|
|
74964
|
+
case 'y':
|
|
74965
|
+
return {
|
|
74966
|
+
x: canvasX * nrrd.image.nrrd_x_pixel / nrrd.image.nrrd_x_mm,
|
|
74967
|
+
y: sliceIndex,
|
|
74968
|
+
z: (nrrd.image.nrrd_z_mm - canvasY) * nrrd.image.nrrd_z_pixel / nrrd.image.nrrd_z_mm,
|
|
74969
|
+
};
|
|
74970
|
+
case 'x':
|
|
74971
|
+
return {
|
|
74972
|
+
x: sliceIndex,
|
|
74973
|
+
y: canvasY * nrrd.image.nrrd_y_pixel / nrrd.image.nrrd_y_mm,
|
|
74974
|
+
z: canvasX * nrrd.image.nrrd_z_pixel / nrrd.image.nrrd_z_mm,
|
|
74975
|
+
};
|
|
74976
|
+
}
|
|
74977
|
+
}
|
|
74978
|
+
/**
|
|
74979
|
+
* Convert mm radius to per-axis voxel radii.
|
|
74980
|
+
*/
|
|
74981
|
+
getVoxelRadii(radius) {
|
|
74982
|
+
const nrrd = this.ctx.nrrd_states;
|
|
74983
|
+
return {
|
|
74984
|
+
rx: radius * nrrd.image.nrrd_x_pixel / nrrd.image.nrrd_x_mm,
|
|
74985
|
+
ry: radius * nrrd.image.nrrd_y_pixel / nrrd.image.nrrd_y_mm,
|
|
74986
|
+
rz: radius * nrrd.image.nrrd_z_pixel / nrrd.image.nrrd_z_mm,
|
|
74987
|
+
};
|
|
74988
|
+
}
|
|
74989
|
+
/**
|
|
74990
|
+
* Compute clamped bounding box for an ellipsoid in voxel space.
|
|
74991
|
+
*/
|
|
74992
|
+
getBoundingBox(center, radii, dims) {
|
|
74993
|
+
return {
|
|
74994
|
+
minX: Math.max(0, Math.floor(center.x - radii.rx)),
|
|
74995
|
+
maxX: Math.min(dims.width - 1, Math.ceil(center.x + radii.rx)),
|
|
74996
|
+
minY: Math.max(0, Math.floor(center.y - radii.ry)),
|
|
74997
|
+
maxY: Math.min(dims.height - 1, Math.ceil(center.y + radii.ry)),
|
|
74998
|
+
minZ: Math.max(0, Math.floor(center.z - radii.rz)),
|
|
74999
|
+
maxZ: Math.min(dims.depth - 1, Math.ceil(center.z + radii.rz)),
|
|
75000
|
+
};
|
|
75001
|
+
}
|
|
75002
|
+
// ── 3D Volume Write / Erase ────────────────────────────────────
|
|
75003
|
+
/**
|
|
75004
|
+
* Write a 3D sphere of the active channel label into the layer's MaskVolume.
|
|
75005
|
+
*/
|
|
75006
|
+
write3DSphereToBrush(vol, center, radii, bb, label) {
|
|
75007
|
+
const { rx, ry, rz } = radii;
|
|
75008
|
+
for (let z = bb.minZ; z <= bb.maxZ; z++) {
|
|
75009
|
+
for (let y = bb.minY; y <= bb.maxY; y++) {
|
|
75010
|
+
for (let x = bb.minX; x <= bb.maxX; x++) {
|
|
75011
|
+
const dx = rx > 0 ? (x - center.x) / rx : 0;
|
|
75012
|
+
const dy = ry > 0 ? (y - center.y) / ry : 0;
|
|
75013
|
+
const dz = rz > 0 ? (z - center.z) / rz : 0;
|
|
75014
|
+
if (dx * dx + dy * dy + dz * dz <= 1.0) {
|
|
75015
|
+
vol.setVoxel(x, y, z, label);
|
|
75016
|
+
}
|
|
75017
|
+
}
|
|
75018
|
+
}
|
|
75019
|
+
}
|
|
75020
|
+
}
|
|
75021
|
+
/**
|
|
75022
|
+
* Erase a 3D sphere from the layer's MaskVolume.
|
|
75023
|
+
* Only clears voxels whose current value equals the active channel label.
|
|
75024
|
+
*/
|
|
75025
|
+
erase3DSphereFromVolume(vol, center, radii, bb, label) {
|
|
75026
|
+
const { rx, ry, rz } = radii;
|
|
75027
|
+
for (let z = bb.minZ; z <= bb.maxZ; z++) {
|
|
75028
|
+
for (let y = bb.minY; y <= bb.maxY; y++) {
|
|
75029
|
+
for (let x = bb.minX; x <= bb.maxX; x++) {
|
|
75030
|
+
const dx = rx > 0 ? (x - center.x) / rx : 0;
|
|
75031
|
+
const dy = ry > 0 ? (y - center.y) / ry : 0;
|
|
75032
|
+
const dz = rz > 0 ? (z - center.z) / rz : 0;
|
|
75033
|
+
if (dx * dx + dy * dy + dz * dz <= 1.0) {
|
|
75034
|
+
if (vol.getVoxel(x, y, z) === label) {
|
|
75035
|
+
vol.setVoxel(x, y, z, 0);
|
|
75036
|
+
}
|
|
75037
|
+
}
|
|
75038
|
+
}
|
|
75039
|
+
}
|
|
75040
|
+
}
|
|
75041
|
+
}
|
|
75042
|
+
// ── Undo Capture ───────────────────────────────────────────────
|
|
75043
|
+
/**
|
|
75044
|
+
* Capture slice snapshots for all Z-slices in the bounding box.
|
|
75045
|
+
* Used before and after the sphere operation to build MaskDelta groups.
|
|
75046
|
+
*
|
|
75047
|
+
* We always capture along the Z axis since the 3D sphere spans Z slices.
|
|
75048
|
+
*/
|
|
75049
|
+
captureSliceSnapshots(vol, minZ, maxZ) {
|
|
75050
|
+
const snapshots = new Map();
|
|
75051
|
+
for (let z = minZ; z <= maxZ; z++) {
|
|
75052
|
+
try {
|
|
75053
|
+
const { data } = vol.getSliceUint8(z, 'z');
|
|
75054
|
+
snapshots.set(z, data.slice());
|
|
75055
|
+
}
|
|
75056
|
+
catch (_a) {
|
|
75057
|
+
// slice out of bounds — skip
|
|
75058
|
+
}
|
|
75059
|
+
}
|
|
75060
|
+
return snapshots;
|
|
75061
|
+
}
|
|
75062
|
+
/**
|
|
75063
|
+
* Build MaskDelta group from before/after snapshots.
|
|
75064
|
+
*/
|
|
75065
|
+
buildUndoGroup(layerId, before, after) {
|
|
75066
|
+
const deltas = [];
|
|
75067
|
+
for (const [sliceIndex, oldSlice] of before) {
|
|
75068
|
+
const newSlice = after.get(sliceIndex);
|
|
75069
|
+
if (!newSlice)
|
|
75070
|
+
continue;
|
|
75071
|
+
// Only include slices that actually changed
|
|
75072
|
+
let changed = false;
|
|
75073
|
+
for (let i = 0; i < oldSlice.length; i++) {
|
|
75074
|
+
if (oldSlice[i] !== newSlice[i]) {
|
|
75075
|
+
changed = true;
|
|
75076
|
+
break;
|
|
75077
|
+
}
|
|
75078
|
+
}
|
|
75079
|
+
if (changed) {
|
|
75080
|
+
deltas.push({ layerId, axis: 'z', sliceIndex, oldSlice, newSlice });
|
|
75081
|
+
}
|
|
75082
|
+
}
|
|
75083
|
+
return deltas;
|
|
75084
|
+
}
|
|
75085
|
+
// ── Preview Rendering ──────────────────────────────────────────
|
|
75086
|
+
/**
|
|
75087
|
+
* Draw a preview circle on the sphere canvas during sphere placement.
|
|
75088
|
+
*/
|
|
75089
|
+
drawPreview(mouseX, mouseY, radius, isEraser) {
|
|
75090
|
+
const sphereCanvas = this.ctx.protectedData.canvases.drawingSphereCanvas;
|
|
75091
|
+
const sphereCtx = this.ctx.protectedData.ctxes.drawingSphereCtx;
|
|
75092
|
+
// Clear and resize sphere canvas
|
|
75093
|
+
sphereCanvas.width = this.ctx.protectedData.canvases.originCanvas.width;
|
|
75094
|
+
sphereCanvas.height = this.ctx.protectedData.canvases.originCanvas.height;
|
|
75095
|
+
sphereCtx.beginPath();
|
|
75096
|
+
sphereCtx.arc(mouseX, mouseY, radius, 0, 2 * Math.PI);
|
|
75097
|
+
if (isEraser) {
|
|
75098
|
+
// Eraser preview: dashed outline
|
|
75099
|
+
sphereCtx.strokeStyle = "#ff4444";
|
|
75100
|
+
sphereCtx.lineWidth = 2;
|
|
75101
|
+
sphereCtx.setLineDash([4, 4]);
|
|
75102
|
+
sphereCtx.stroke();
|
|
75103
|
+
sphereCtx.setLineDash([]);
|
|
75104
|
+
}
|
|
75105
|
+
else {
|
|
75106
|
+
// Brush preview: semi-transparent fill with active channel color
|
|
75107
|
+
const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
|
|
75108
|
+
const color = this.getActiveChannelColor(channel);
|
|
75109
|
+
sphereCtx.fillStyle = color;
|
|
75110
|
+
sphereCtx.globalAlpha = 0.5;
|
|
75111
|
+
sphereCtx.fill();
|
|
75112
|
+
sphereCtx.globalAlpha = 1.0;
|
|
75113
|
+
}
|
|
75114
|
+
sphereCtx.closePath();
|
|
75115
|
+
}
|
|
75116
|
+
/**
|
|
75117
|
+
* Clear the sphere preview canvas.
|
|
75118
|
+
*/
|
|
75119
|
+
clearPreview() {
|
|
75120
|
+
const sphereCanvas = this.ctx.protectedData.canvases.drawingSphereCanvas;
|
|
75121
|
+
sphereCanvas.width = sphereCanvas.width;
|
|
75122
|
+
}
|
|
75123
|
+
/**
|
|
75124
|
+
* Get the hex color for the active channel from the layer's MaskVolume.
|
|
75125
|
+
*/
|
|
75126
|
+
getActiveChannelColor(channel) {
|
|
75127
|
+
var _a;
|
|
75128
|
+
const layer = this.ctx.gui_states.layerChannel.layer;
|
|
75129
|
+
const volumes = this.ctx.protectedData.maskData.volumes;
|
|
75130
|
+
const vol = volumes[layer];
|
|
75131
|
+
if (vol) {
|
|
75132
|
+
const rgba = vol.getChannelColor(channel);
|
|
75133
|
+
const r = rgba.r.toString(16).padStart(2, '0');
|
|
75134
|
+
const g = rgba.g.toString(16).padStart(2, '0');
|
|
75135
|
+
const b = rgba.b.toString(16).padStart(2, '0');
|
|
75136
|
+
return `#${r}${g}${b}`;
|
|
75137
|
+
}
|
|
75138
|
+
return (_a = CHANNEL_HEX_COLORS[channel]) !== null && _a !== void 0 ? _a : "#ff0000";
|
|
75139
|
+
}
|
|
75140
|
+
// ── Wheel Handler ──────────────────────────────────────────────
|
|
75141
|
+
/**
|
|
75142
|
+
* Returns a wheel event handler for adjusting sphereBrushRadius.
|
|
75143
|
+
* Used in both sphereBrush and sphereEraser modes.
|
|
75144
|
+
*/
|
|
75145
|
+
configSphereBrushWheel() {
|
|
75146
|
+
return (e) => {
|
|
75147
|
+
e.preventDefault();
|
|
75148
|
+
const sphere = this.ctx.nrrd_states.sphere;
|
|
75149
|
+
if (e.deltaY < 0) {
|
|
75150
|
+
sphere.sphereBrushRadius += 1;
|
|
75151
|
+
}
|
|
75152
|
+
else {
|
|
75153
|
+
sphere.sphereBrushRadius -= 1;
|
|
75154
|
+
}
|
|
75155
|
+
sphere.sphereBrushRadius = Math.max(1, Math.min(sphere.sphereBrushRadius, 50));
|
|
75156
|
+
// Redraw preview
|
|
75157
|
+
if (this.active) {
|
|
75158
|
+
this.drawPreview(this.centerX, this.centerY, sphere.sphereBrushRadius, this.mode === "eraser");
|
|
75159
|
+
}
|
|
75160
|
+
};
|
|
75161
|
+
}
|
|
75162
|
+
// ── Sphere Brush Click/PointerUp ───────────────────────────────
|
|
75163
|
+
/**
|
|
75164
|
+
* Handle pointer-down in sphereBrush mode (direct click, no Shift needed).
|
|
75165
|
+
*/
|
|
75166
|
+
onSphereBrushClick(e) {
|
|
75167
|
+
this.centerX = e.offsetX / this.ctx.nrrd_states.view.sizeFactor;
|
|
75168
|
+
this.centerY = e.offsetY / this.ctx.nrrd_states.view.sizeFactor;
|
|
75169
|
+
this.centerSlice = this.ctx.nrrd_states.view.currentSliceIndex;
|
|
75170
|
+
this.active = true;
|
|
75171
|
+
this.mode = "brush";
|
|
75172
|
+
this.drawPreview(this.centerX, this.centerY, this.ctx.nrrd_states.sphere.sphereBrushRadius, false);
|
|
75173
|
+
}
|
|
75174
|
+
/**
|
|
75175
|
+
* Handle pointer-up in sphereBrush mode — write sphere to volume.
|
|
75176
|
+
*/
|
|
75177
|
+
onSphereBrushPointerUp() {
|
|
75178
|
+
if (!this.active || this.mode !== "brush")
|
|
75179
|
+
return;
|
|
75180
|
+
this.active = false;
|
|
75181
|
+
const layer = this.ctx.gui_states.layerChannel.layer;
|
|
75182
|
+
const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
|
|
75183
|
+
const axis = this.ctx.protectedData.axis;
|
|
75184
|
+
const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
|
|
75185
|
+
let vol;
|
|
75186
|
+
try {
|
|
75187
|
+
vol = this.callbacks.getVolumeForLayer(layer);
|
|
75188
|
+
}
|
|
75189
|
+
catch (_a) {
|
|
75190
|
+
this.clearPreview();
|
|
75191
|
+
return;
|
|
75192
|
+
}
|
|
75193
|
+
const dims = vol.getDimensions();
|
|
75194
|
+
const center = this.canvasToVoxelCenter(this.centerX, this.centerY, this.centerSlice, axis);
|
|
75195
|
+
const radii = this.getVoxelRadii(radius);
|
|
75196
|
+
const bb = this.getBoundingBox(center, radii, dims);
|
|
75197
|
+
// Capture pre-write snapshots
|
|
75198
|
+
const before = this.captureSliceSnapshots(vol, bb.minZ, bb.maxZ);
|
|
75199
|
+
// Write sphere
|
|
75200
|
+
this.write3DSphereToBrush(vol, center, radii, bb, channel);
|
|
75201
|
+
// Capture post-write snapshots and push undo group
|
|
75202
|
+
const after = this.captureSliceSnapshots(vol, bb.minZ, bb.maxZ);
|
|
75203
|
+
const deltas = this.buildUndoGroup(layer, before, after);
|
|
75204
|
+
if (deltas.length > 0) {
|
|
75205
|
+
this.callbacks.pushUndoGroup(deltas);
|
|
75206
|
+
}
|
|
75207
|
+
// Refresh display and notify backend of ALL changed slices
|
|
75208
|
+
this.refreshDisplay(layer, deltas);
|
|
75209
|
+
this.clearPreview();
|
|
75210
|
+
}
|
|
75211
|
+
// ── Sphere Eraser Click/PointerUp ──────────────────────────────
|
|
75212
|
+
/**
|
|
75213
|
+
* Handle pointer-down in sphereEraser mode.
|
|
75214
|
+
* Initializes drag tracking for cumulative undo.
|
|
75215
|
+
*/
|
|
75216
|
+
onSphereEraserClick(e) {
|
|
75217
|
+
this.centerX = e.offsetX / this.ctx.nrrd_states.view.sizeFactor;
|
|
75218
|
+
this.centerY = e.offsetY / this.ctx.nrrd_states.view.sizeFactor;
|
|
75219
|
+
this.centerSlice = this.ctx.nrrd_states.view.currentSliceIndex;
|
|
75220
|
+
this.active = true;
|
|
75221
|
+
this.mode = "eraser";
|
|
75222
|
+
this.dragMoved = false;
|
|
75223
|
+
this.dragBeforeSnapshots.clear();
|
|
75224
|
+
// Capture initial "before" snapshots for the first sphere position
|
|
75225
|
+
const layer = this.ctx.gui_states.layerChannel.layer;
|
|
75226
|
+
const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
|
|
75227
|
+
try {
|
|
75228
|
+
const vol = this.callbacks.getVolumeForLayer(layer);
|
|
75229
|
+
const dims = vol.getDimensions();
|
|
75230
|
+
const axis = this.ctx.protectedData.axis;
|
|
75231
|
+
const center = this.canvasToVoxelCenter(this.centerX, this.centerY, this.centerSlice, axis);
|
|
75232
|
+
const radii = this.getVoxelRadii(radius);
|
|
75233
|
+
const bb = this.getBoundingBox(center, radii, dims);
|
|
75234
|
+
this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
|
|
75235
|
+
}
|
|
75236
|
+
catch (_a) {
|
|
75237
|
+
// Volume not ready
|
|
75238
|
+
}
|
|
75239
|
+
this.drawPreview(this.centerX, this.centerY, radius, true);
|
|
75240
|
+
}
|
|
75241
|
+
/**
|
|
75242
|
+
* Handle pointer-move in sphereEraser mode — drag to continuously erase.
|
|
75243
|
+
*/
|
|
75244
|
+
onSphereEraserMove(e) {
|
|
75245
|
+
if (!this.active || this.mode !== "eraser")
|
|
75246
|
+
return;
|
|
75247
|
+
this.dragMoved = true;
|
|
75248
|
+
const mouseX = e.offsetX / this.ctx.nrrd_states.view.sizeFactor;
|
|
75249
|
+
const mouseY = e.offsetY / this.ctx.nrrd_states.view.sizeFactor;
|
|
75250
|
+
const sliceIndex = this.ctx.nrrd_states.view.currentSliceIndex;
|
|
75251
|
+
const layer = this.ctx.gui_states.layerChannel.layer;
|
|
75252
|
+
const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
|
|
75253
|
+
const axis = this.ctx.protectedData.axis;
|
|
75254
|
+
const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
|
|
75255
|
+
let vol;
|
|
75256
|
+
try {
|
|
75257
|
+
vol = this.callbacks.getVolumeForLayer(layer);
|
|
75258
|
+
}
|
|
75259
|
+
catch (_a) {
|
|
75260
|
+
return;
|
|
75261
|
+
}
|
|
75262
|
+
const dims = vol.getDimensions();
|
|
75263
|
+
const center = this.canvasToVoxelCenter(mouseX, mouseY, sliceIndex, axis);
|
|
75264
|
+
const radii = this.getVoxelRadii(radius);
|
|
75265
|
+
const bb = this.getBoundingBox(center, radii, dims);
|
|
75266
|
+
// Expand "before" snapshots to cover any new Z slices
|
|
75267
|
+
this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
|
|
75268
|
+
// Erase sphere at current position
|
|
75269
|
+
this.erase3DSphereFromVolume(vol, center, radii, bb, channel);
|
|
75270
|
+
// Update preview position
|
|
75271
|
+
this.drawPreview(mouseX, mouseY, radius, true);
|
|
75272
|
+
// Refresh display so user sees the erase in real-time
|
|
75273
|
+
this.refreshDisplay(layer);
|
|
75274
|
+
}
|
|
75275
|
+
/**
|
|
75276
|
+
* Handle pointer-up in sphereEraser mode — finalize erase + push undo group.
|
|
75277
|
+
* Works for both click-release and drag-release.
|
|
75278
|
+
*/
|
|
75279
|
+
onSphereEraserPointerUp() {
|
|
75280
|
+
if (!this.active || this.mode !== "eraser")
|
|
75281
|
+
return;
|
|
75282
|
+
this.active = false;
|
|
75283
|
+
const layer = this.ctx.gui_states.layerChannel.layer;
|
|
75284
|
+
const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
|
|
75285
|
+
const axis = this.ctx.protectedData.axis;
|
|
75286
|
+
const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
|
|
75287
|
+
let vol;
|
|
75288
|
+
try {
|
|
75289
|
+
vol = this.callbacks.getVolumeForLayer(layer);
|
|
75290
|
+
}
|
|
75291
|
+
catch (_a) {
|
|
75292
|
+
this.clearPreview();
|
|
75293
|
+
this.dragBeforeSnapshots.clear();
|
|
75294
|
+
return;
|
|
75295
|
+
}
|
|
75296
|
+
// If no drag occurred, erase at the click position (original click-release behavior)
|
|
75297
|
+
if (!this.dragMoved) {
|
|
75298
|
+
const dims = vol.getDimensions();
|
|
75299
|
+
const center = this.canvasToVoxelCenter(this.centerX, this.centerY, this.centerSlice, axis);
|
|
75300
|
+
const radii = this.getVoxelRadii(radius);
|
|
75301
|
+
const bb = this.getBoundingBox(center, radii, dims);
|
|
75302
|
+
this.erase3DSphereFromVolume(vol, center, radii, bb, channel);
|
|
75303
|
+
}
|
|
75304
|
+
// Build undo group from cumulative "before" snapshots vs current state
|
|
75305
|
+
const after = this.captureSliceSnapshots(vol, this.dragMinZ(), this.dragMaxZ());
|
|
75306
|
+
const deltas = this.buildUndoGroup(layer, this.dragBeforeSnapshots, after);
|
|
75307
|
+
if (deltas.length > 0) {
|
|
75308
|
+
this.callbacks.pushUndoGroup(deltas);
|
|
75309
|
+
}
|
|
75310
|
+
// Refresh display and notify backend of ALL changed slices
|
|
75311
|
+
this.refreshDisplay(layer, deltas);
|
|
75312
|
+
this.clearPreview();
|
|
75313
|
+
this.dragBeforeSnapshots.clear();
|
|
75314
|
+
}
|
|
75315
|
+
/**
|
|
75316
|
+
* Expand cumulative "before" snapshots to cover z range [minZ, maxZ].
|
|
75317
|
+
* Only captures slices not yet in the map (preserves original state).
|
|
75318
|
+
*/
|
|
75319
|
+
expandDragBeforeSnapshots(vol, minZ, maxZ) {
|
|
75320
|
+
for (let z = minZ; z <= maxZ; z++) {
|
|
75321
|
+
if (!this.dragBeforeSnapshots.has(z)) {
|
|
75322
|
+
try {
|
|
75323
|
+
const { data } = vol.getSliceUint8(z, 'z');
|
|
75324
|
+
this.dragBeforeSnapshots.set(z, data.slice());
|
|
75325
|
+
}
|
|
75326
|
+
catch (_a) {
|
|
75327
|
+
// slice out of bounds
|
|
75328
|
+
}
|
|
75329
|
+
}
|
|
75330
|
+
}
|
|
75331
|
+
}
|
|
75332
|
+
/** Get minimum Z index from drag snapshots */
|
|
75333
|
+
dragMinZ() {
|
|
75334
|
+
let min = Infinity;
|
|
75335
|
+
for (const z of this.dragBeforeSnapshots.keys()) {
|
|
75336
|
+
if (z < min)
|
|
75337
|
+
min = z;
|
|
75338
|
+
}
|
|
75339
|
+
return min === Infinity ? 0 : min;
|
|
75340
|
+
}
|
|
75341
|
+
/** Get maximum Z index from drag snapshots */
|
|
75342
|
+
dragMaxZ() {
|
|
75343
|
+
let max = -Infinity;
|
|
75344
|
+
for (const z of this.dragBeforeSnapshots.keys()) {
|
|
75345
|
+
if (z > max)
|
|
75346
|
+
max = z;
|
|
75347
|
+
}
|
|
75348
|
+
return max === -Infinity ? 0 : max;
|
|
75349
|
+
}
|
|
75350
|
+
// ── Post-write Display Refresh ─────────────────────────────────
|
|
75351
|
+
/**
|
|
75352
|
+
* After writing/erasing the sphere in the volume, re-render the current
|
|
75353
|
+
* slice's layer canvas and composite all layers to master.
|
|
75354
|
+
*
|
|
75355
|
+
* @param changedDeltas - If provided, fire onMaskChanged for ALL changed
|
|
75356
|
+
* slices (not just the current view slice) so the backend receives the
|
|
75357
|
+
* full 3D sphere data for NII/GLTF export.
|
|
75358
|
+
*/
|
|
75359
|
+
refreshDisplay(layerId, changedDeltas) {
|
|
75360
|
+
// Re-render layer canvas from volume for the current slice
|
|
75361
|
+
const target = this.ctx.protectedData.layerTargets.get(layerId);
|
|
75362
|
+
if (target) {
|
|
75363
|
+
const { ctx, canvas } = target;
|
|
75364
|
+
canvas.width = canvas.width; // clear
|
|
75365
|
+
const buffer = this.callbacks.getOrCreateSliceBuffer(this.ctx.protectedData.axis);
|
|
75366
|
+
if (buffer) {
|
|
75367
|
+
this.callbacks.renderSliceToCanvas(layerId, this.ctx.protectedData.axis, this.ctx.nrrd_states.view.currentSliceIndex, buffer, ctx, this.ctx.nrrd_states.view.changedWidth, this.ctx.nrrd_states.view.changedHeight);
|
|
75368
|
+
}
|
|
75369
|
+
}
|
|
75370
|
+
// Composite all layers to master canvas
|
|
75371
|
+
this.callbacks.compositeAllLayers();
|
|
75372
|
+
// Fire onMaskChanged for ALL changed slices (not just the current view slice)
|
|
75373
|
+
// This ensures the backend receives the full 3D sphere data for export.
|
|
75374
|
+
try {
|
|
75375
|
+
const vol = this.callbacks.getVolumeForLayer(layerId);
|
|
75376
|
+
const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
|
|
75377
|
+
if (changedDeltas && changedDeltas.length > 0) {
|
|
75378
|
+
// Report every changed Z slice
|
|
75379
|
+
for (const delta of changedDeltas) {
|
|
75380
|
+
const { data: sliceData, width, height } = vol.getSliceUint8(delta.sliceIndex, delta.axis);
|
|
75381
|
+
this.ctx.callbacks.onMaskChanged(sliceData, layerId, channel, delta.sliceIndex, delta.axis, width, height, false);
|
|
75382
|
+
}
|
|
75383
|
+
}
|
|
75384
|
+
else {
|
|
75385
|
+
// Fallback: report only the current slice
|
|
75386
|
+
const axis = this.ctx.protectedData.axis;
|
|
75387
|
+
const sliceIndex = this.ctx.nrrd_states.view.currentSliceIndex;
|
|
75388
|
+
const { data: sliceData, width, height } = vol.getSliceUint8(sliceIndex, axis);
|
|
75389
|
+
this.ctx.callbacks.onMaskChanged(sliceData, layerId, channel, sliceIndex, axis, width, height, false);
|
|
75390
|
+
}
|
|
75391
|
+
}
|
|
75392
|
+
catch (_a) {
|
|
75393
|
+
// Volume not ready
|
|
75394
|
+
}
|
|
75395
|
+
}
|
|
75396
|
+
/** Whether a sphere placement is currently in progress */
|
|
75397
|
+
get isActive() {
|
|
75398
|
+
return this.active;
|
|
75399
|
+
}
|
|
75400
|
+
}
|
|
75401
|
+
|
|
74904
75402
|
/**
|
|
74905
75403
|
* DrawToolCore — Tool orchestration and event routing.
|
|
74906
75404
|
*
|
|
@@ -74921,6 +75419,7 @@ class DrawToolCore {
|
|
|
74921
75419
|
handleOnDrawingBrushCricleMove: (ev) => { },
|
|
74922
75420
|
handleMouseZoomSliceWheel: (e) => { },
|
|
74923
75421
|
handleSphereWheel: (e) => { },
|
|
75422
|
+
handleSphereBrushWheel: (e) => { },
|
|
74924
75423
|
};
|
|
74925
75424
|
this.contrastEventPrameters = {
|
|
74926
75425
|
move_x: 0,
|
|
@@ -75003,6 +75502,16 @@ class DrawToolCore {
|
|
|
75003
75502
|
pushUndoDelta: (delta) => this.undoManager.push(delta),
|
|
75004
75503
|
getEraserUrls: () => this.eraserUrls,
|
|
75005
75504
|
});
|
|
75505
|
+
this.sphereBrushTool = new SphereBrushTool(toolCtx, {
|
|
75506
|
+
getVolumeForLayer: (layer) => this.renderer.getVolumeForLayer(layer),
|
|
75507
|
+
compositeAllLayers: () => this.renderer.compositeAllLayers(),
|
|
75508
|
+
pushUndoGroup: (deltas) => this.undoManager.pushGroup(deltas),
|
|
75509
|
+
renderSliceToCanvas: (layer, axis, sliceIndex, buffer, ctx, w, h) => this.renderer.renderSliceToCanvas(layer, axis, sliceIndex, buffer, ctx, w, h),
|
|
75510
|
+
getOrCreateSliceBuffer: (axis) => this.renderer.getOrCreateSliceBuffer(axis),
|
|
75511
|
+
setEmptyCanvasSize: (axis) => this.setEmptyCanvasSize(axis),
|
|
75512
|
+
reloadMasksFromVolume: () => this.reloadMasksFromVolume(),
|
|
75513
|
+
getEraserUrls: () => this.eraserUrls,
|
|
75514
|
+
});
|
|
75006
75515
|
}
|
|
75007
75516
|
initDrawToolCore() {
|
|
75008
75517
|
// Initialize EventRouter for centralized event handling
|
|
@@ -75047,12 +75556,28 @@ class DrawToolCore {
|
|
|
75047
75556
|
if ((ev.ctrlKey || ev.metaKey) && (ev.key === redoKey || (ev.shiftKey && ev.key === undoKeyUpper))) {
|
|
75048
75557
|
this.redoLastPainting();
|
|
75049
75558
|
}
|
|
75559
|
+
// Handle mouse wheel mode toggle (Ctrl+1 = Scroll:Zoom, Ctrl+2 = Scroll:Slice)
|
|
75560
|
+
if ((ev.ctrlKey || ev.metaKey) && ev.key === '1') {
|
|
75561
|
+
ev.preventDefault();
|
|
75562
|
+
this.state.keyboardSettings.mouseWheel = 'Scroll:Zoom';
|
|
75563
|
+
this.updateMouseWheelEvent();
|
|
75564
|
+
return;
|
|
75565
|
+
}
|
|
75566
|
+
if ((ev.ctrlKey || ev.metaKey) && ev.key === '2') {
|
|
75567
|
+
ev.preventDefault();
|
|
75568
|
+
this.state.keyboardSettings.mouseWheel = 'Scroll:Slice';
|
|
75569
|
+
this.updateMouseWheelEvent();
|
|
75570
|
+
return;
|
|
75571
|
+
}
|
|
75050
75572
|
// Handle crosshair toggle (allowed in drawing tools AND sphere mode)
|
|
75051
75573
|
if (ev.key === this.state.keyboardSettings.crosshair) {
|
|
75052
75574
|
this.eventRouter.toggleCrosshair();
|
|
75053
75575
|
}
|
|
75054
75576
|
// Handle draw mode (Shift key) - EventRouter already tracks this
|
|
75055
|
-
if (ev.key === this.state.keyboardSettings.draw
|
|
75577
|
+
if (ev.key === this.state.keyboardSettings.draw
|
|
75578
|
+
&& !this.state.gui_states.mode.sphere
|
|
75579
|
+
&& !this.state.gui_states.mode.sphereBrush
|
|
75580
|
+
&& !this.state.gui_states.mode.sphereEraser) {
|
|
75056
75581
|
if (this.eventRouter.isCtrlHeld()) {
|
|
75057
75582
|
return; // Ctrl takes priority
|
|
75058
75583
|
}
|
|
@@ -75104,11 +75629,16 @@ class DrawToolCore {
|
|
|
75104
75629
|
if (this.drawingTool.isActive || this.panTool.isActive) {
|
|
75105
75630
|
this.drawingPrameters.handleOnDrawingMouseMove(e);
|
|
75106
75631
|
}
|
|
75632
|
+
// Drag-erase: route move to sphereBrushTool when sphereEraser is active
|
|
75633
|
+
if (this.sphereBrushTool.isActive && this.state.gui_states.mode.sphereEraser) {
|
|
75634
|
+
this.sphereBrushTool.onSphereEraserMove(e);
|
|
75635
|
+
}
|
|
75107
75636
|
});
|
|
75108
75637
|
this.eventRouter.setPointerUpHandler((e) => {
|
|
75109
75638
|
if (this.drawingTool.isActive || this.drawingTool.painting
|
|
75110
75639
|
|| this.panTool.isActive
|
|
75111
|
-
|| (this.state.gui_states.mode.sphere && this.eventRouter.getMode() !== 'crosshair')
|
|
75640
|
+
|| (this.state.gui_states.mode.sphere && this.eventRouter.getMode() !== 'crosshair')
|
|
75641
|
+
|| this.sphereBrushTool.isActive) {
|
|
75112
75642
|
this.drawingPrameters.handleOnDrawingMouseUp(e);
|
|
75113
75643
|
}
|
|
75114
75644
|
});
|
|
@@ -75123,6 +75653,9 @@ class DrawToolCore {
|
|
|
75123
75653
|
else if (this.activeWheelMode === 'sphere') {
|
|
75124
75654
|
this.drawingPrameters.handleSphereWheel(e);
|
|
75125
75655
|
}
|
|
75656
|
+
else if (this.activeWheelMode === 'sphereBrush') {
|
|
75657
|
+
this.drawingPrameters.handleSphereBrushWheel(e);
|
|
75658
|
+
}
|
|
75126
75659
|
});
|
|
75127
75660
|
// Bind all event listeners
|
|
75128
75661
|
this.eventRouter.bindAll();
|
|
@@ -75186,6 +75719,8 @@ class DrawToolCore {
|
|
|
75186
75719
|
}
|
|
75187
75720
|
// sphere Wheel
|
|
75188
75721
|
this.drawingPrameters.handleSphereWheel = this.configMouseSphereWheel();
|
|
75722
|
+
// sphere brush Wheel
|
|
75723
|
+
this.drawingPrameters.handleSphereBrushWheel = this.sphereBrushTool.configSphereBrushWheel();
|
|
75189
75724
|
// brush circle move — delegated to DrawingTool
|
|
75190
75725
|
this.drawingPrameters.handleOnDrawingBrushCricleMove =
|
|
75191
75726
|
this.drawingTool.createBrushTrackingHandler();
|
|
@@ -75216,6 +75751,16 @@ class DrawToolCore {
|
|
|
75216
75751
|
e.offsetY / this.state.nrrd_states.view.sizeFactor;
|
|
75217
75752
|
this.enableCrosshair();
|
|
75218
75753
|
}
|
|
75754
|
+
else if (this.state.gui_states.mode.sphereBrush && !this.eventRouter.isCrosshairEnabled()) {
|
|
75755
|
+
// SphereBrush: direct click (no Shift needed)
|
|
75756
|
+
this.activeWheelMode = 'sphereBrush';
|
|
75757
|
+
this.sphereBrushTool.onSphereBrushClick(e);
|
|
75758
|
+
}
|
|
75759
|
+
else if (this.state.gui_states.mode.sphereEraser && !this.eventRouter.isCrosshairEnabled()) {
|
|
75760
|
+
// SphereEraser: direct click (Shift handled at mode level)
|
|
75761
|
+
this.activeWheelMode = 'sphereBrush';
|
|
75762
|
+
this.sphereBrushTool.onSphereEraserClick(e);
|
|
75763
|
+
}
|
|
75219
75764
|
else if (this.state.gui_states.mode.sphere && !this.eventRouter.isCrosshairEnabled()) {
|
|
75220
75765
|
this.handleSphereClick(e);
|
|
75221
75766
|
}
|
|
@@ -75237,6 +75782,16 @@ class DrawToolCore {
|
|
|
75237
75782
|
this.drawingTool.onPointerUp(e);
|
|
75238
75783
|
this.activeWheelMode = 'zoom';
|
|
75239
75784
|
}
|
|
75785
|
+
else if (this.sphereBrushTool.isActive) {
|
|
75786
|
+
// SphereBrush or SphereEraser pointer-up
|
|
75787
|
+
if (this.state.gui_states.mode.sphereBrush) {
|
|
75788
|
+
this.sphereBrushTool.onSphereBrushPointerUp();
|
|
75789
|
+
}
|
|
75790
|
+
else if (this.state.gui_states.mode.sphereEraser) {
|
|
75791
|
+
this.sphereBrushTool.onSphereEraserPointerUp();
|
|
75792
|
+
}
|
|
75793
|
+
this.activeWheelMode = 'zoom';
|
|
75794
|
+
}
|
|
75240
75795
|
else if (this.state.gui_states.mode.sphere &&
|
|
75241
75796
|
!this.eventRouter.isCrosshairEnabled()) {
|
|
75242
75797
|
this.sphereTool.onSpherePointerUp();
|
|
@@ -75281,7 +75836,9 @@ class DrawToolCore {
|
|
|
75281
75836
|
}
|
|
75282
75837
|
}
|
|
75283
75838
|
this.state.protectedData.ctxes.drawingCtx.drawImage(this.state.protectedData.canvases.drawingCanvasLayerMaster, 0, 0);
|
|
75284
|
-
if (this.state.gui_states.mode.sphere
|
|
75839
|
+
if (this.state.gui_states.mode.sphere
|
|
75840
|
+
|| this.state.gui_states.mode.sphereBrush
|
|
75841
|
+
|| this.state.gui_states.mode.sphereEraser) {
|
|
75285
75842
|
this.state.protectedData.ctxes.drawingCtx.drawImage(this.state.protectedData.canvases.drawingSphereCanvas, 0, 0, this.state.nrrd_states.view.changedWidth, this.state.nrrd_states.view.changedHeight);
|
|
75286
75843
|
}
|
|
75287
75844
|
}
|
|
@@ -75468,24 +76025,26 @@ class DrawToolCore {
|
|
|
75468
76025
|
* Undo the last drawing operation on the active layer.
|
|
75469
76026
|
*/
|
|
75470
76027
|
undoLastPainting() {
|
|
75471
|
-
const
|
|
75472
|
-
if (!
|
|
75473
|
-
return;
|
|
75474
|
-
try {
|
|
75475
|
-
const vol = this.renderer.getVolumeForLayer(delta.layerId);
|
|
75476
|
-
vol.setSliceUint8(delta.sliceIndex, delta.oldSlice, delta.axis);
|
|
75477
|
-
}
|
|
75478
|
-
catch (_a) {
|
|
76028
|
+
const entry = this.undoManager.undo();
|
|
76029
|
+
if (!entry)
|
|
75479
76030
|
return;
|
|
75480
|
-
}
|
|
75481
76031
|
this.state.protectedData.isDrawing = true;
|
|
75482
|
-
|
|
75483
|
-
|
|
75484
|
-
|
|
75485
|
-
|
|
75486
|
-
|
|
75487
|
-
|
|
75488
|
-
|
|
76032
|
+
for (const delta of entry) {
|
|
76033
|
+
try {
|
|
76034
|
+
const vol = this.renderer.getVolumeForLayer(delta.layerId);
|
|
76035
|
+
vol.setSliceUint8(delta.sliceIndex, delta.oldSlice, delta.axis);
|
|
76036
|
+
}
|
|
76037
|
+
catch (_a) {
|
|
76038
|
+
continue;
|
|
76039
|
+
}
|
|
76040
|
+
if (delta.axis === this.state.protectedData.axis && delta.sliceIndex === this.state.nrrd_states.view.currentSliceIndex) {
|
|
76041
|
+
this.applyUndoRedoToCanvas(delta.layerId);
|
|
76042
|
+
}
|
|
76043
|
+
if (!this.state.nrrd_states.flags.loadingMaskData) {
|
|
76044
|
+
const { data: sliceData, width, height } = this.renderer.getVolumeForLayer(delta.layerId)
|
|
76045
|
+
.getSliceUint8(delta.sliceIndex, delta.axis);
|
|
76046
|
+
this.state.annotationCallbacks.onMaskChanged(sliceData, delta.layerId, this.state.gui_states.layerChannel.activeChannel || 1, delta.sliceIndex, delta.axis, width, height, false);
|
|
76047
|
+
}
|
|
75489
76048
|
}
|
|
75490
76049
|
this.setIsDrawFalse(1000);
|
|
75491
76050
|
}
|
|
@@ -75493,24 +76052,26 @@ class DrawToolCore {
|
|
|
75493
76052
|
* Redo the last undone operation on the active layer.
|
|
75494
76053
|
*/
|
|
75495
76054
|
redoLastPainting() {
|
|
75496
|
-
const
|
|
75497
|
-
if (!
|
|
76055
|
+
const entry = this.undoManager.redo();
|
|
76056
|
+
if (!entry)
|
|
75498
76057
|
return;
|
|
75499
|
-
try {
|
|
75500
|
-
const vol = this.renderer.getVolumeForLayer(delta.layerId);
|
|
75501
|
-
vol.setSliceUint8(delta.sliceIndex, delta.newSlice, delta.axis);
|
|
75502
|
-
}
|
|
75503
|
-
catch (_a) {
|
|
75504
|
-
return;
|
|
75505
|
-
}
|
|
75506
76058
|
this.state.protectedData.isDrawing = true;
|
|
75507
|
-
|
|
75508
|
-
|
|
75509
|
-
|
|
75510
|
-
|
|
75511
|
-
|
|
75512
|
-
|
|
75513
|
-
|
|
76059
|
+
for (const delta of entry) {
|
|
76060
|
+
try {
|
|
76061
|
+
const vol = this.renderer.getVolumeForLayer(delta.layerId);
|
|
76062
|
+
vol.setSliceUint8(delta.sliceIndex, delta.newSlice, delta.axis);
|
|
76063
|
+
}
|
|
76064
|
+
catch (_a) {
|
|
76065
|
+
continue;
|
|
76066
|
+
}
|
|
76067
|
+
if (delta.axis === this.state.protectedData.axis && delta.sliceIndex === this.state.nrrd_states.view.currentSliceIndex) {
|
|
76068
|
+
this.applyUndoRedoToCanvas(delta.layerId);
|
|
76069
|
+
}
|
|
76070
|
+
if (!this.state.nrrd_states.flags.loadingMaskData) {
|
|
76071
|
+
const { data: sliceData, width, height } = this.renderer.getVolumeForLayer(delta.layerId)
|
|
76072
|
+
.getSliceUint8(delta.sliceIndex, delta.axis);
|
|
76073
|
+
this.state.annotationCallbacks.onMaskChanged(sliceData, delta.layerId, this.state.gui_states.layerChannel.activeChannel || 1, delta.sliceIndex, delta.axis, width, height, false);
|
|
76074
|
+
}
|
|
75514
76075
|
}
|
|
75515
76076
|
this.setIsDrawFalse(1000);
|
|
75516
76077
|
}
|
|
@@ -75584,6 +76145,14 @@ class DrawToolCore {
|
|
|
75584
76145
|
exitSphereMode() {
|
|
75585
76146
|
throw new Error("exitSphereMode must be provided by NrrdTools");
|
|
75586
76147
|
}
|
|
76148
|
+
/** Override in NrrdTools */
|
|
76149
|
+
reloadMasksFromVolume() {
|
|
76150
|
+
throw new Error("reloadMasksFromVolume must be provided by NrrdTools");
|
|
76151
|
+
}
|
|
76152
|
+
/** Override in NrrdTools */
|
|
76153
|
+
updateMouseWheelEvent() {
|
|
76154
|
+
throw new Error("updateMouseWheelEvent must be provided by NrrdTools");
|
|
76155
|
+
}
|
|
75587
76156
|
}
|
|
75588
76157
|
|
|
75589
76158
|
/**
|
|
@@ -75647,6 +76216,7 @@ class NrrdState {
|
|
|
75647
76216
|
nippleSphereOrigin: null,
|
|
75648
76217
|
sphereMaskVolume: null,
|
|
75649
76218
|
sphereRadius: 5,
|
|
76219
|
+
sphereBrushRadius: 10,
|
|
75650
76220
|
};
|
|
75651
76221
|
this.flags = {
|
|
75652
76222
|
stepClear: 1,
|
|
@@ -75668,6 +76238,7 @@ class NrrdState {
|
|
|
75668
76238
|
this.sphere.nippleSphereOrigin = null;
|
|
75669
76239
|
this.sphere.sphereMaskVolume = null;
|
|
75670
76240
|
this.sphere.sphereRadius = 5;
|
|
76241
|
+
this.sphere.sphereBrushRadius = 10;
|
|
75671
76242
|
}
|
|
75672
76243
|
}
|
|
75673
76244
|
|
|
@@ -75689,6 +76260,8 @@ class GuiState {
|
|
|
75689
76260
|
pencil: true,
|
|
75690
76261
|
eraser: false,
|
|
75691
76262
|
sphere: false,
|
|
76263
|
+
sphereBrush: false,
|
|
76264
|
+
sphereEraser: false,
|
|
75692
76265
|
activeSphereType: "tumour",
|
|
75693
76266
|
};
|
|
75694
76267
|
this.drawing = {
|
|
@@ -75883,6 +76456,216 @@ class CanvasState {
|
|
|
75883
76456
|
}
|
|
75884
76457
|
}
|
|
75885
76458
|
|
|
76459
|
+
/**
|
|
76460
|
+
* GaussianSmoother — 3D Gaussian smoothing for segmentation masks.
|
|
76461
|
+
*
|
|
76462
|
+
* Applies a separable 3D Gaussian blur to a single label channel within a
|
|
76463
|
+
* MaskVolume, then thresholds the result back to a binary mask. This smooths
|
|
76464
|
+
* jagged edges and fills small holes in segmentation annotations.
|
|
76465
|
+
*
|
|
76466
|
+
* Pure, stateless utility — no DOM/Canvas/GUI dependencies.
|
|
76467
|
+
*
|
|
76468
|
+
* Performance: uses direct typed-array access (bypassing getVoxel/setVoxel
|
|
76469
|
+
* boundary checks) and branch-free convolution for the interior region.
|
|
76470
|
+
*/
|
|
76471
|
+
class GaussianSmoother {
|
|
76472
|
+
/**
|
|
76473
|
+
* Smooth a single label channel in-place using separable 3D Gaussian blur.
|
|
76474
|
+
*
|
|
76475
|
+
* Steps:
|
|
76476
|
+
* 1. Extract — create a Float32Array with 1.0 where voxel === channel, 0.0 elsewhere.
|
|
76477
|
+
* 2. Blur — apply separable Gaussian (X → Y → Z) on the float buffer.
|
|
76478
|
+
* 3. Threshold — binarize at 0.5.
|
|
76479
|
+
* 4. Write back — overwrite/erase voxels according to the thresholded result.
|
|
76480
|
+
*
|
|
76481
|
+
* @param volume The MaskVolume to operate on (modified in-place).
|
|
76482
|
+
* @param channel The label value to smooth (must be > 0).
|
|
76483
|
+
* @param sigma Base Gaussian sigma in voxel units (default 1.0).
|
|
76484
|
+
* @param spacing Optional voxel spacing [sx, sy, sz]. When provided, per-axis
|
|
76485
|
+
* sigma is computed as `sigma / spacing[axis]` so that the
|
|
76486
|
+
* physical smoothing radius is isotropic.
|
|
76487
|
+
*/
|
|
76488
|
+
static gaussianSmooth3D(volume, channel, sigma = 1.0, spacing) {
|
|
76489
|
+
const dims = volume.getDimensions();
|
|
76490
|
+
const { width, height, depth } = dims;
|
|
76491
|
+
const totalVoxels = width * height * depth;
|
|
76492
|
+
// Direct access to underlying buffer — bypasses getVoxel/setVoxel
|
|
76493
|
+
// boundary checks for ~10x speedup in hot loops.
|
|
76494
|
+
const rawData = volume.getRawData();
|
|
76495
|
+
const channels = volume.getChannels();
|
|
76496
|
+
const bytesPerSlice = volume.getBytesPerSlice();
|
|
76497
|
+
const rowStride = width * channels;
|
|
76498
|
+
// Compute per-axis sigma (normalized to voxel units)
|
|
76499
|
+
const sigmaX = spacing ? sigma / spacing[0] : sigma;
|
|
76500
|
+
const sigmaY = spacing ? sigma / spacing[1] : sigma;
|
|
76501
|
+
const sigmaZ = spacing ? sigma / spacing[2] : sigma;
|
|
76502
|
+
// 1. Extract binary float buffer — direct array access
|
|
76503
|
+
const buffer = new Float32Array(totalVoxels);
|
|
76504
|
+
for (let z = 0; z < depth; z++) {
|
|
76505
|
+
const zOffset = z * bytesPerSlice;
|
|
76506
|
+
for (let y = 0; y < height; y++) {
|
|
76507
|
+
const zyOffset = zOffset + y * rowStride;
|
|
76508
|
+
for (let x = 0; x < width; x++) {
|
|
76509
|
+
const bufIdx = z * height * width + y * width + x;
|
|
76510
|
+
buffer[bufIdx] = rawData[zyOffset + x * channels] === channel ? 1.0 : 0.0;
|
|
76511
|
+
}
|
|
76512
|
+
}
|
|
76513
|
+
}
|
|
76514
|
+
// 2. Separable Gaussian blur: X → Y → Z
|
|
76515
|
+
const kernelX = GaussianSmoother.generateKernel1D(sigmaX);
|
|
76516
|
+
const kernelY = GaussianSmoother.generateKernel1D(sigmaY);
|
|
76517
|
+
const kernelZ = GaussianSmoother.generateKernel1D(sigmaZ);
|
|
76518
|
+
GaussianSmoother.convolve1D(buffer, width, height, depth, 0, kernelX); // X axis
|
|
76519
|
+
GaussianSmoother.convolve1D(buffer, width, height, depth, 1, kernelY); // Y axis
|
|
76520
|
+
GaussianSmoother.convolve1D(buffer, width, height, depth, 2, kernelZ); // Z axis
|
|
76521
|
+
// 3 & 4. Threshold and write back — direct array access
|
|
76522
|
+
for (let z = 0; z < depth; z++) {
|
|
76523
|
+
const zOffset = z * bytesPerSlice;
|
|
76524
|
+
for (let y = 0; y < height; y++) {
|
|
76525
|
+
const zyOffset = zOffset + y * rowStride;
|
|
76526
|
+
for (let x = 0; x < width; x++) {
|
|
76527
|
+
const bufIdx = z * height * width + y * width + x;
|
|
76528
|
+
const smoothed = buffer[bufIdx] >= 0.5 ? 1 : 0;
|
|
76529
|
+
const rawIdx = zyOffset + x * channels;
|
|
76530
|
+
const original = rawData[rawIdx];
|
|
76531
|
+
if (smoothed === 1 && original !== channel) {
|
|
76532
|
+
// Smoothed region expands into this voxel — overwrite
|
|
76533
|
+
rawData[rawIdx] = channel;
|
|
76534
|
+
}
|
|
76535
|
+
else if (smoothed === 0 && original === channel) {
|
|
76536
|
+
// Smoothed region retreats — erase
|
|
76537
|
+
rawData[rawIdx] = 0;
|
|
76538
|
+
}
|
|
76539
|
+
// Otherwise leave unchanged
|
|
76540
|
+
}
|
|
76541
|
+
}
|
|
76542
|
+
}
|
|
76543
|
+
}
|
|
76544
|
+
/**
|
|
76545
|
+
* Generate a normalized 1D Gaussian kernel truncated at ±3σ.
|
|
76546
|
+
*
|
|
76547
|
+
* @param sigma Standard deviation (in voxels). Must be > 0.
|
|
76548
|
+
* @returns Normalized Float32Array of odd length.
|
|
76549
|
+
*/
|
|
76550
|
+
static generateKernel1D(sigma) {
|
|
76551
|
+
if (sigma <= 0) {
|
|
76552
|
+
return new Float32Array([1.0]);
|
|
76553
|
+
}
|
|
76554
|
+
const radius = Math.ceil(sigma * 3);
|
|
76555
|
+
const size = 2 * radius + 1;
|
|
76556
|
+
const kernel = new Float32Array(size);
|
|
76557
|
+
const twoSigmaSq = 2 * sigma * sigma;
|
|
76558
|
+
let sum = 0;
|
|
76559
|
+
for (let i = 0; i < size; i++) {
|
|
76560
|
+
const x = i - radius;
|
|
76561
|
+
kernel[i] = Math.exp(-(x * x) / twoSigmaSq);
|
|
76562
|
+
sum += kernel[i];
|
|
76563
|
+
}
|
|
76564
|
+
// Normalize
|
|
76565
|
+
for (let i = 0; i < size; i++) {
|
|
76566
|
+
kernel[i] /= sum;
|
|
76567
|
+
}
|
|
76568
|
+
return kernel;
|
|
76569
|
+
}
|
|
76570
|
+
/**
|
|
76571
|
+
* In-place single-axis convolution with zero-padding at boundaries.
|
|
76572
|
+
*
|
|
76573
|
+
* Uses a 3-segment approach: left boundary (with bounds check),
|
|
76574
|
+
* middle interior (branch-free, ~95% of work), right boundary
|
|
76575
|
+
* (with bounds check). This eliminates per-element branching
|
|
76576
|
+
* in the hot inner loop.
|
|
76577
|
+
*
|
|
76578
|
+
* @param data Flat float buffer (width × height × depth).
|
|
76579
|
+
* @param width X dimension.
|
|
76580
|
+
* @param height Y dimension.
|
|
76581
|
+
* @param depth Z dimension.
|
|
76582
|
+
* @param axis 0 = X, 1 = Y, 2 = Z.
|
|
76583
|
+
* @param kernel 1D convolution kernel (odd length).
|
|
76584
|
+
*/
|
|
76585
|
+
static convolve1D(data, width, height, depth, axis, kernel) {
|
|
76586
|
+
const kLen = kernel.length;
|
|
76587
|
+
const radius = (kLen - 1) / 2;
|
|
76588
|
+
// Determine the dimension length along the convolution axis
|
|
76589
|
+
// and the stride between consecutive elements along that axis
|
|
76590
|
+
let axisLen;
|
|
76591
|
+
let stride;
|
|
76592
|
+
let outerCount;
|
|
76593
|
+
if (axis === 0) {
|
|
76594
|
+
// X axis: stride = 1, iterate over all (y, z) lines
|
|
76595
|
+
axisLen = width;
|
|
76596
|
+
stride = 1;
|
|
76597
|
+
outerCount = height * depth;
|
|
76598
|
+
}
|
|
76599
|
+
else if (axis === 1) {
|
|
76600
|
+
// Y axis: stride = width, iterate over all (x, z) lines
|
|
76601
|
+
axisLen = height;
|
|
76602
|
+
stride = width;
|
|
76603
|
+
outerCount = width * depth;
|
|
76604
|
+
}
|
|
76605
|
+
else {
|
|
76606
|
+
// Z axis: stride = width * height, iterate over all (x, y) lines
|
|
76607
|
+
axisLen = depth;
|
|
76608
|
+
stride = width * height;
|
|
76609
|
+
outerCount = width * height;
|
|
76610
|
+
}
|
|
76611
|
+
// Precompute middle segment bounds
|
|
76612
|
+
const midStart = radius;
|
|
76613
|
+
const midEnd = axisLen - radius;
|
|
76614
|
+
// Temporary line buffer to avoid read-after-write issues
|
|
76615
|
+
const line = new Float32Array(axisLen);
|
|
76616
|
+
for (let outer = 0; outer < outerCount; outer++) {
|
|
76617
|
+
// Compute the starting index for this line
|
|
76618
|
+
let lineStart;
|
|
76619
|
+
if (axis === 0) {
|
|
76620
|
+
lineStart = outer * width;
|
|
76621
|
+
}
|
|
76622
|
+
else if (axis === 1) {
|
|
76623
|
+
const z = Math.floor(outer / width);
|
|
76624
|
+
const x = outer % width;
|
|
76625
|
+
lineStart = z * width * height + x;
|
|
76626
|
+
}
|
|
76627
|
+
else {
|
|
76628
|
+
lineStart = outer;
|
|
76629
|
+
}
|
|
76630
|
+
// Read the line
|
|
76631
|
+
for (let i = 0; i < axisLen; i++) {
|
|
76632
|
+
line[i] = data[lineStart + i * stride];
|
|
76633
|
+
}
|
|
76634
|
+
// Left boundary segment (i = 0 .. radius-1): lower bound check
|
|
76635
|
+
for (let i = 0; i < midStart; i++) {
|
|
76636
|
+
let sum = 0;
|
|
76637
|
+
for (let k = 0; k < kLen; k++) {
|
|
76638
|
+
const j = i + k - radius;
|
|
76639
|
+
if (j >= 0) {
|
|
76640
|
+
sum += line[j] * kernel[k];
|
|
76641
|
+
}
|
|
76642
|
+
}
|
|
76643
|
+
data[lineStart + i * stride] = sum;
|
|
76644
|
+
}
|
|
76645
|
+
// Middle segment (i = radius .. axisLen-radius-1): NO bounds check
|
|
76646
|
+
for (let i = midStart; i < midEnd; i++) {
|
|
76647
|
+
let sum = 0;
|
|
76648
|
+
const lineOffset = i - radius;
|
|
76649
|
+
for (let k = 0; k < kLen; k++) {
|
|
76650
|
+
sum += line[lineOffset + k] * kernel[k];
|
|
76651
|
+
}
|
|
76652
|
+
data[lineStart + i * stride] = sum;
|
|
76653
|
+
}
|
|
76654
|
+
// Right boundary segment (i = axisLen-radius .. axisLen-1): upper bound check
|
|
76655
|
+
for (let i = midEnd; i < axisLen; i++) {
|
|
76656
|
+
let sum = 0;
|
|
76657
|
+
for (let k = 0; k < kLen; k++) {
|
|
76658
|
+
const j = i + k - radius;
|
|
76659
|
+
if (j < axisLen) {
|
|
76660
|
+
sum += line[j] * kernel[k];
|
|
76661
|
+
}
|
|
76662
|
+
}
|
|
76663
|
+
data[lineStart + i * stride] = sum;
|
|
76664
|
+
}
|
|
76665
|
+
}
|
|
76666
|
+
}
|
|
76667
|
+
}
|
|
76668
|
+
|
|
75886
76669
|
/**
|
|
75887
76670
|
* Manages layer/channel state: active selection, visibility, and channel colors.
|
|
75888
76671
|
*
|
|
@@ -76399,6 +77182,8 @@ class SliceRenderPipeline extends BaseTool {
|
|
|
76399
77182
|
* Then update the changedWidth and changedHeight based on the sizeFactor.
|
|
76400
77183
|
*/
|
|
76401
77184
|
updateOriginAndChangedWH() {
|
|
77185
|
+
const prevW = this.ctx.nrrd_states.image.originWidth;
|
|
77186
|
+
const prevH = this.ctx.nrrd_states.image.originHeight;
|
|
76402
77187
|
this.ctx.nrrd_states.image.originWidth =
|
|
76403
77188
|
this.ctx.protectedData.canvases.originCanvas.width;
|
|
76404
77189
|
this.ctx.nrrd_states.image.originHeight =
|
|
@@ -76407,7 +77192,12 @@ class SliceRenderPipeline extends BaseTool {
|
|
|
76407
77192
|
// Setting them here would defeat the sizeChanged detection in resizePaintArea,
|
|
76408
77193
|
// causing canvas elements to keep stale dimensions after axis switches.
|
|
76409
77194
|
this.resizePaintArea(this.ctx.nrrd_states.view.sizeFactor);
|
|
76410
|
-
|
|
77195
|
+
// Only re-center when origin dimensions changed (e.g. axis switch).
|
|
77196
|
+
// Contrast toggle doesn't change origin size, so skip to preserve user's pan/zoom.
|
|
77197
|
+
if (prevW !== this.ctx.nrrd_states.image.originWidth ||
|
|
77198
|
+
prevH !== this.ctx.nrrd_states.image.originHeight) {
|
|
77199
|
+
this.resetPaintAreaUIPosition();
|
|
77200
|
+
}
|
|
76411
77201
|
}
|
|
76412
77202
|
/**
|
|
76413
77203
|
* Keep all contrast slice index to same.
|
|
@@ -76621,7 +77411,7 @@ class NrrdTools {
|
|
|
76621
77411
|
// Wire RenderingUtils' setEmptyCanvasSize callback
|
|
76622
77412
|
this.drawCore.renderer.setEmptyCanvasSize = (axis) => this.setEmptyCanvasSize(axis);
|
|
76623
77413
|
this.init();
|
|
76624
|
-
this.dragOperator = new DragOperator(this.container, this.state.nrrd_states, this.state.gui_states, this.state.protectedData, this.drawCore.drawingPrameters, this.setSyncsliceNum.bind(this), this.setIsDrawFalse.bind(this), this.flipDisplayImageByAxis.bind(this), this.setEmptyCanvasSize.bind(this), this.drawCore.renderer.getOrCreateSliceBuffer.bind(this.drawCore.renderer), this.drawCore.renderer.renderSliceToCanvas.bind(this.drawCore.renderer));
|
|
77414
|
+
this.dragOperator = new DragOperator(this.container, this.state.nrrd_states, this.state.gui_states, this.state.protectedData, this.drawCore.drawingPrameters, this.setSyncsliceNum.bind(this), this.setIsDrawFalse.bind(this), this.flipDisplayImageByAxis.bind(this), this.setEmptyCanvasSize.bind(this), this.drawCore.renderer.getOrCreateSliceBuffer.bind(this.drawCore.renderer), this.drawCore.renderer.renderSliceToCanvas.bind(this.drawCore.renderer), this.drawCore.renderer.compositeAllLayers.bind(this.drawCore.renderer));
|
|
76625
77415
|
// Inject EventRouter into DragOperator for centralized event handling
|
|
76626
77416
|
if (this.drawCore.eventRouter) {
|
|
76627
77417
|
this.dragOperator.setEventRouter(this.drawCore.eventRouter);
|
|
@@ -76648,6 +77438,8 @@ class NrrdTools {
|
|
|
76648
77438
|
this.drawCore.enterSphereMode = () => this.enterSphereMode();
|
|
76649
77439
|
this.drawCore.exitSphereMode = () => this.exitSphereMode();
|
|
76650
77440
|
this.drawCore.configMouseSliceWheel = () => this.configMouseSliceWheel();
|
|
77441
|
+
this.drawCore.reloadMasksFromVolume = () => this.reloadMasksFromVolume();
|
|
77442
|
+
this.drawCore.updateMouseWheelEvent = () => this.updateMouseWheelEvent();
|
|
76651
77443
|
}
|
|
76652
77444
|
/**
|
|
76653
77445
|
* A initialise function for nrrd_tools
|
|
@@ -76768,13 +77560,18 @@ class NrrdTools {
|
|
|
76768
77560
|
* Set the current tool mode.
|
|
76769
77561
|
*/
|
|
76770
77562
|
setMode(mode) {
|
|
77563
|
+
var _a, _b, _c;
|
|
76771
77564
|
if (!this.guiCallbacks)
|
|
76772
77565
|
return;
|
|
76773
77566
|
const prevSphere = this.state.gui_states.mode.sphere;
|
|
77567
|
+
const prevSphereBrush = this.state.gui_states.mode.sphereBrush;
|
|
77568
|
+
const prevSphereEraser = this.state.gui_states.mode.sphereEraser;
|
|
76774
77569
|
const prevCalculator = this._calculatorActive;
|
|
76775
77570
|
this.state.gui_states.mode.pencil = false;
|
|
76776
77571
|
this.state.gui_states.mode.eraser = false;
|
|
76777
77572
|
this.state.gui_states.mode.sphere = false;
|
|
77573
|
+
this.state.gui_states.mode.sphereBrush = false;
|
|
77574
|
+
this.state.gui_states.mode.sphereEraser = false;
|
|
76778
77575
|
this._calculatorActive = false;
|
|
76779
77576
|
switch (mode) {
|
|
76780
77577
|
case "pencil":
|
|
@@ -76795,6 +77592,24 @@ class NrrdTools {
|
|
|
76795
77592
|
this.state.gui_states.mode.sphere = true;
|
|
76796
77593
|
this._calculatorActive = true;
|
|
76797
77594
|
break;
|
|
77595
|
+
case "sphereBrush":
|
|
77596
|
+
this.state.gui_states.mode.sphereBrush = true;
|
|
77597
|
+
this.dragOperator.removeDragMode();
|
|
77598
|
+
(_a = this.drawCore.eventRouter) === null || _a === void 0 ? void 0 : _a.setGuiTool('sphereBrush');
|
|
77599
|
+
break;
|
|
77600
|
+
case "sphereEraser":
|
|
77601
|
+
this.state.gui_states.mode.sphereEraser = true;
|
|
77602
|
+
this.dragOperator.removeDragMode();
|
|
77603
|
+
(_b = this.drawCore.eventRouter) === null || _b === void 0 ? void 0 : _b.setGuiTool('sphereEraser');
|
|
77604
|
+
break;
|
|
77605
|
+
}
|
|
77606
|
+
// Restore drag mode when leaving sphereBrush/sphereEraser
|
|
77607
|
+
if ((prevSphereBrush || prevSphereEraser)
|
|
77608
|
+
&& !this.state.gui_states.mode.sphereBrush
|
|
77609
|
+
&& !this.state.gui_states.mode.sphereEraser
|
|
77610
|
+
&& !this.state.gui_states.mode.sphere) {
|
|
77611
|
+
this.dragOperator.configDragMode();
|
|
77612
|
+
(_c = this.drawCore.eventRouter) === null || _c === void 0 ? void 0 : _c.setGuiTool('pencil');
|
|
76798
77613
|
}
|
|
76799
77614
|
if (prevSphere && !this.state.gui_states.mode.sphere) {
|
|
76800
77615
|
this.guiCallbacks.updateSphereState();
|
|
@@ -76807,6 +77622,10 @@ class NrrdTools {
|
|
|
76807
77622
|
getMode() {
|
|
76808
77623
|
if (this._calculatorActive)
|
|
76809
77624
|
return "calculator";
|
|
77625
|
+
if (this.state.gui_states.mode.sphereBrush)
|
|
77626
|
+
return "sphereBrush";
|
|
77627
|
+
if (this.state.gui_states.mode.sphereEraser)
|
|
77628
|
+
return "sphereEraser";
|
|
76810
77629
|
if (this.state.gui_states.mode.sphere)
|
|
76811
77630
|
return "sphere";
|
|
76812
77631
|
if (this.state.gui_states.mode.eraser)
|
|
@@ -76832,6 +77651,12 @@ class NrrdTools {
|
|
|
76832
77651
|
getBrushSize() {
|
|
76833
77652
|
return this.state.gui_states.drawing.brushAndEraserSize;
|
|
76834
77653
|
}
|
|
77654
|
+
setSphereBrushRadius(radius) {
|
|
77655
|
+
this.state.nrrd_states.sphere.sphereBrushRadius = Math.max(1, Math.min(50, radius));
|
|
77656
|
+
}
|
|
77657
|
+
getSphereBrushRadius() {
|
|
77658
|
+
return this.state.nrrd_states.sphere.sphereBrushRadius;
|
|
77659
|
+
}
|
|
76835
77660
|
setWindowHigh(value) {
|
|
76836
77661
|
var _a;
|
|
76837
77662
|
this.state.gui_states.viewConfig.readyToUpdate = false;
|
|
@@ -76905,7 +77730,8 @@ class NrrdTools {
|
|
|
76905
77730
|
getPencilColor() {
|
|
76906
77731
|
return this.state.gui_states.drawing.color;
|
|
76907
77732
|
}
|
|
76908
|
-
executeAction(action) {
|
|
77733
|
+
executeAction(action, opts) {
|
|
77734
|
+
var _a;
|
|
76909
77735
|
switch (action) {
|
|
76910
77736
|
case "undo":
|
|
76911
77737
|
this.undo();
|
|
@@ -76943,6 +77769,48 @@ class NrrdTools {
|
|
|
76943
77769
|
enableDownload(config);
|
|
76944
77770
|
break;
|
|
76945
77771
|
}
|
|
77772
|
+
case "gaussianSmooth": {
|
|
77773
|
+
const layerId = this.state.gui_states.layerChannel.layer;
|
|
77774
|
+
const channel = this.state.gui_states.layerChannel.activeChannel;
|
|
77775
|
+
const volume = this.drawCore.renderer.getVolumeForLayer(layerId);
|
|
77776
|
+
if (volume && channel > 0) {
|
|
77777
|
+
const dims = volume.getDimensions();
|
|
77778
|
+
// 1. Snapshot all Z-slices before mutation (undo support)
|
|
77779
|
+
const deltas = [];
|
|
77780
|
+
for (let z = 0; z < dims.depth; z++) {
|
|
77781
|
+
const sliceData = volume.getSliceUint8(z, "z").data;
|
|
77782
|
+
const hasChannel = sliceData.some((v) => v === channel);
|
|
77783
|
+
if (hasChannel) {
|
|
77784
|
+
deltas.push({
|
|
77785
|
+
layerId,
|
|
77786
|
+
axis: "z",
|
|
77787
|
+
sliceIndex: z,
|
|
77788
|
+
oldSlice: sliceData.slice(),
|
|
77789
|
+
newSlice: new Uint8Array(0), // placeholder, filled after smoothing
|
|
77790
|
+
});
|
|
77791
|
+
}
|
|
77792
|
+
}
|
|
77793
|
+
// 2. Apply smoothing with anisotropic spacing
|
|
77794
|
+
const spacing = this.state.nrrd_states.image.voxelSpacing;
|
|
77795
|
+
const spacingTuple = spacing.length >= 3 ? [spacing[0], spacing[1], spacing[2]] : undefined;
|
|
77796
|
+
const sigma = (_a = opts === null || opts === void 0 ? void 0 : opts.sigma) !== null && _a !== void 0 ? _a : 1.0;
|
|
77797
|
+
GaussianSmoother.gaussianSmooth3D(volume, channel, sigma, spacingTuple);
|
|
77798
|
+
// 3. Capture newSlice data after smoothing and push undo group
|
|
77799
|
+
for (const delta of deltas) {
|
|
77800
|
+
delta.newSlice = volume.getSliceUint8(delta.sliceIndex, "z").data.slice();
|
|
77801
|
+
}
|
|
77802
|
+
if (deltas.length > 0) {
|
|
77803
|
+
this.drawCore.undoManager.pushGroup(deltas);
|
|
77804
|
+
}
|
|
77805
|
+
// 4. Notify backend for each changed slice
|
|
77806
|
+
for (const delta of deltas) {
|
|
77807
|
+
const { data: sliceData, width, height } = volume.getSliceUint8(delta.sliceIndex, "z");
|
|
77808
|
+
this.state.annotationCallbacks.onMaskChanged(sliceData, layerId, channel, delta.sliceIndex, "z", width, height, false);
|
|
77809
|
+
}
|
|
77810
|
+
this.reloadMasksFromVolume();
|
|
77811
|
+
}
|
|
77812
|
+
break;
|
|
77813
|
+
}
|
|
76946
77814
|
}
|
|
76947
77815
|
}
|
|
76948
77816
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -77381,10 +78249,15 @@ class NrrdTools {
|
|
|
77381
78249
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
77382
78250
|
configMouseSliceWheel() {
|
|
77383
78251
|
const handleMouseZoomSliceWheelMove = (e) => {
|
|
77384
|
-
var _a;
|
|
78252
|
+
var _a, _b;
|
|
77385
78253
|
if ((_a = this.drawCore.eventRouter) === null || _a === void 0 ? void 0 : _a.isShiftHeld()) {
|
|
77386
78254
|
return;
|
|
77387
78255
|
}
|
|
78256
|
+
// Block slice wheel when sphereBrush/sphereEraser is actively placing (left button held)
|
|
78257
|
+
if ((this.state.gui_states.mode.sphereBrush || this.state.gui_states.mode.sphereEraser)
|
|
78258
|
+
&& ((_b = this.drawCore.eventRouter) === null || _b === void 0 ? void 0 : _b.isLeftButtonDown())) {
|
|
78259
|
+
return;
|
|
78260
|
+
}
|
|
77388
78261
|
e.preventDefault();
|
|
77389
78262
|
if (e.deltaY < 0) {
|
|
77390
78263
|
this.setSliceMoving(-1);
|
|
@@ -77652,7 +78525,7 @@ function evaluateElement(element, weights) {
|
|
|
77652
78525
|
}
|
|
77653
78526
|
|
|
77654
78527
|
// import * as kiwrious from "copper3d_plugin_heart_k";
|
|
77655
|
-
const REVISION = "v3.1
|
|
78528
|
+
const REVISION = "v3.2.1-beta";
|
|
77656
78529
|
console.log(`%cCopper3D Visualisation %cBeta:${REVISION}`, "padding: 3px;color:white; background:#023047", "padding: 3px;color:white; background:#f50a25");
|
|
77657
78530
|
|
|
77658
|
-
export { CHANNEL_COLORS, CHANNEL_HEX_COLORS, CameraViewPoint, Copper3dTrackballControls, MeshNodeTool, NrrdTools, REVISION, addBoxHelper, addLabelToScene, configKiwriousHeart, convert3DPostoScreenPos, convertScreenPosto3DPos, copperMScene, copperMSceneRenderer, copperRenderer, copperRendererOnDemond, copperScene, copperSceneOnDemond, createTexture2D_NRRD, fullScreenListenner, kiwrious, loading, removeGuiFolderChilden, rgbaToCss, rgbaToHex, setHDRFilePath, throttle };
|
|
78531
|
+
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 };
|