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.umd.js
CHANGED
|
@@ -71395,6 +71395,17 @@ void main() {
|
|
|
71395
71395
|
getChannels() {
|
|
71396
71396
|
return this.numChannels;
|
|
71397
71397
|
}
|
|
71398
|
+
/**
|
|
71399
|
+
* Return the number of bytes in one complete z-slice.
|
|
71400
|
+
*
|
|
71401
|
+
* Equal to `width × height × channels`. Useful for direct buffer
|
|
71402
|
+
* index arithmetic in performance-critical code paths.
|
|
71403
|
+
*
|
|
71404
|
+
* @returns Bytes per z-slice.
|
|
71405
|
+
*/
|
|
71406
|
+
getBytesPerSlice() {
|
|
71407
|
+
return this.bytesPerSlice;
|
|
71408
|
+
}
|
|
71398
71409
|
/**
|
|
71399
71410
|
* Return total memory used by the backing buffer, in bytes.
|
|
71400
71411
|
*
|
|
@@ -71963,41 +71974,48 @@ void main() {
|
|
|
71963
71974
|
setActiveLayer(layer) {
|
|
71964
71975
|
this.activeLayer = layer;
|
|
71965
71976
|
}
|
|
71966
|
-
/** Push a delta onto the active layer's undo stack and clear the redo stack. */
|
|
71977
|
+
/** Push a single delta onto the active layer's undo stack and clear the redo stack. */
|
|
71967
71978
|
push(delta) {
|
|
71979
|
+
this.pushGroup([delta]);
|
|
71980
|
+
}
|
|
71981
|
+
/** Push a group of deltas as a single undo unit (e.g. 3D sphere spanning multiple slices). */
|
|
71982
|
+
pushGroup(deltas) {
|
|
71968
71983
|
var _a, _b;
|
|
71969
|
-
|
|
71970
|
-
|
|
71984
|
+
if (deltas.length === 0)
|
|
71985
|
+
return;
|
|
71986
|
+
const layerId = deltas[0].layerId;
|
|
71987
|
+
const stack = (_a = this.undoStacks.get(layerId)) !== null && _a !== void 0 ? _a : this.undoStacks.get("layer1");
|
|
71988
|
+
stack.push(deltas);
|
|
71971
71989
|
if (stack.length > MAX_STACK_SIZE) {
|
|
71972
71990
|
stack.shift();
|
|
71973
71991
|
}
|
|
71974
71992
|
// Any new operation invalidates the redo history for that layer
|
|
71975
|
-
const redoStack = (_b = this.redoStacks.get(
|
|
71993
|
+
const redoStack = (_b = this.redoStacks.get(layerId)) !== null && _b !== void 0 ? _b : this.redoStacks.get("layer1");
|
|
71976
71994
|
redoStack.length = 0;
|
|
71977
71995
|
}
|
|
71978
71996
|
/**
|
|
71979
71997
|
* Undo the last operation on the active layer.
|
|
71980
|
-
* @returns The delta that
|
|
71998
|
+
* @returns The delta(s) that were undone, or undefined if nothing to undo.
|
|
71981
71999
|
*/
|
|
71982
72000
|
undo() {
|
|
71983
72001
|
const stack = this.undoStacks.get(this.activeLayer);
|
|
71984
|
-
const
|
|
71985
|
-
if (
|
|
71986
|
-
this.redoStacks.get(this.activeLayer).push(
|
|
72002
|
+
const entry = stack.pop();
|
|
72003
|
+
if (entry) {
|
|
72004
|
+
this.redoStacks.get(this.activeLayer).push(entry);
|
|
71987
72005
|
}
|
|
71988
|
-
return
|
|
72006
|
+
return entry;
|
|
71989
72007
|
}
|
|
71990
72008
|
/**
|
|
71991
72009
|
* Redo the last undone operation on the active layer.
|
|
71992
|
-
* @returns The delta that
|
|
72010
|
+
* @returns The delta(s) that were redone, or undefined if nothing to redo.
|
|
71993
72011
|
*/
|
|
71994
72012
|
redo() {
|
|
71995
72013
|
const stack = this.redoStacks.get(this.activeLayer);
|
|
71996
|
-
const
|
|
71997
|
-
if (
|
|
71998
|
-
this.undoStacks.get(this.activeLayer).push(
|
|
72014
|
+
const entry = stack.pop();
|
|
72015
|
+
if (entry) {
|
|
72016
|
+
this.undoStacks.get(this.activeLayer).push(entry);
|
|
71999
72017
|
}
|
|
72000
|
-
return
|
|
72018
|
+
return entry;
|
|
72001
72019
|
}
|
|
72002
72020
|
canUndo() {
|
|
72003
72021
|
var _a, _b;
|
|
@@ -73205,8 +73223,8 @@ void main() {
|
|
|
73205
73223
|
}
|
|
73206
73224
|
}
|
|
73207
73225
|
}
|
|
73208
|
-
// Composite all layers to master canvas
|
|
73209
|
-
this.compositeAllLayers();
|
|
73226
|
+
// Composite all layers to master canvas (uses per-layer alpha)
|
|
73227
|
+
this.callbacks.compositeAllLayers();
|
|
73210
73228
|
}
|
|
73211
73229
|
// Refresh sphere overlay from volume for the new slice
|
|
73212
73230
|
if (this.ctx.gui_states.mode.sphere) {
|
|
@@ -73215,23 +73233,6 @@ void main() {
|
|
|
73215
73233
|
view.switchSliceFlag = false;
|
|
73216
73234
|
}
|
|
73217
73235
|
}
|
|
73218
|
-
/**
|
|
73219
|
-
* Composite all visible layer canvases to the master display canvas.
|
|
73220
|
-
*/
|
|
73221
|
-
compositeAllLayers() {
|
|
73222
|
-
const masterCtx = this.ctx.protectedData.ctxes.drawingLayerMasterCtx;
|
|
73223
|
-
const width = this.ctx.nrrd_states.view.changedWidth;
|
|
73224
|
-
const height = this.ctx.nrrd_states.view.changedHeight;
|
|
73225
|
-
masterCtx.clearRect(0, 0, width, height);
|
|
73226
|
-
// Master stores full-alpha composite; globalAlpha applied in start() render loop.
|
|
73227
|
-
for (const layerId of this.ctx.nrrd_states.image.layers) {
|
|
73228
|
-
if (!this.ctx.gui_states.layerChannel.layerVisibility[layerId])
|
|
73229
|
-
continue;
|
|
73230
|
-
const target = this.ctx.protectedData.layerTargets.get(layerId);
|
|
73231
|
-
if (target)
|
|
73232
|
-
masterCtx.drawImage(target.canvas, 0, 0, width, height);
|
|
73233
|
-
}
|
|
73234
|
-
}
|
|
73235
73236
|
// ===== Canvas Cleanup =====
|
|
73236
73237
|
cleanCanvases(flag) {
|
|
73237
73238
|
if (flag) {
|
|
@@ -73273,7 +73274,7 @@ void main() {
|
|
|
73273
73274
|
}
|
|
73274
73275
|
|
|
73275
73276
|
class DragOperator {
|
|
73276
|
-
constructor(container, nrrd_sates, gui_states, protectedData, drawingPrameters, setSyncsliceNum, setIsDrawFalse, flipDisplayImageByAxis, setEmptyCanvasSize, getOrCreateSliceBuffer, renderSliceToCanvas) {
|
|
73277
|
+
constructor(container, nrrd_sates, gui_states, protectedData, drawingPrameters, setSyncsliceNum, setIsDrawFalse, flipDisplayImageByAxis, setEmptyCanvasSize, getOrCreateSliceBuffer, renderSliceToCanvas, compositeAllLayers) {
|
|
73277
73278
|
this.dragPrameters = {
|
|
73278
73279
|
move: 0,
|
|
73279
73280
|
y: 0,
|
|
@@ -73309,6 +73310,7 @@ void main() {
|
|
|
73309
73310
|
this.setEmptyCanvasSize = setEmptyCanvasSize;
|
|
73310
73311
|
this.getOrCreateSliceBuffer = getOrCreateSliceBuffer;
|
|
73311
73312
|
this.renderSliceToCanvas = renderSliceToCanvas;
|
|
73313
|
+
this.compositeAllLayers = compositeAllLayers;
|
|
73312
73314
|
this.showDragNumberDiv = createShowSliceNumberDiv();
|
|
73313
73315
|
this.init();
|
|
73314
73316
|
}
|
|
@@ -73334,6 +73336,7 @@ void main() {
|
|
|
73334
73336
|
setEmptyCanvasSize: (axis) => this.setEmptyCanvasSize(axis),
|
|
73335
73337
|
getOrCreateSliceBuffer: (axis) => this.getOrCreateSliceBuffer(axis),
|
|
73336
73338
|
renderSliceToCanvas: (layer, axis, sliceIndex, buffer, targetCtx, w, h) => this.renderSliceToCanvas(layer, axis, sliceIndex, buffer, targetCtx, w, h),
|
|
73339
|
+
compositeAllLayers: () => this.compositeAllLayers(),
|
|
73337
73340
|
refreshSphereOverlay: () => { var _a; return (_a = this.refreshSphereOverlayCb) === null || _a === void 0 ? void 0 : _a.call(this); },
|
|
73338
73341
|
}, this.showDragNumberDiv, dragEffectCanvases);
|
|
73339
73342
|
}
|
|
@@ -73366,7 +73369,9 @@ void main() {
|
|
|
73366
73369
|
// When leaving draw, contrast, or crosshair mode (returning to idle), restore drag mode
|
|
73367
73370
|
// Do NOT restore if sphere mode is active
|
|
73368
73371
|
if ((prev === 'draw' || prev === 'contrast' || prev === 'crosshair') && next === 'idle') {
|
|
73369
|
-
if (!this.gui_states.mode.sphere
|
|
73372
|
+
if (!this.gui_states.mode.sphere
|
|
73373
|
+
&& !this.gui_states.mode.sphereBrush
|
|
73374
|
+
&& !this.gui_states.mode.sphereEraser) {
|
|
73370
73375
|
this.configDragMode();
|
|
73371
73376
|
}
|
|
73372
73377
|
}
|
|
@@ -73905,6 +73910,7 @@ void main() {
|
|
|
73905
73910
|
if (ev.key === this.keyboardSettings.draw) {
|
|
73906
73911
|
this.state.shiftHeld = true;
|
|
73907
73912
|
// Block draw mode when crosshair or contrast is active (mutual exclusion)
|
|
73913
|
+
// Also block when sphereBrush or sphereEraser is active
|
|
73908
73914
|
if (DRAWING_TOOLS.has(this.guiTool) && !this.state.ctrlHeld && !this.state.crosshairEnabled) {
|
|
73909
73915
|
this.setMode('draw');
|
|
73910
73916
|
}
|
|
@@ -74304,10 +74310,15 @@ void main() {
|
|
|
74304
74310
|
configMouseZoomWheel() {
|
|
74305
74311
|
let moveDistance = 1;
|
|
74306
74312
|
return (e) => {
|
|
74307
|
-
var _a;
|
|
74313
|
+
var _a, _b;
|
|
74308
74314
|
if ((_a = this.ctx.eventRouter) === null || _a === void 0 ? void 0 : _a.isShiftHeld()) {
|
|
74309
74315
|
return;
|
|
74310
74316
|
}
|
|
74317
|
+
// Block zoom wheel when sphereBrush/sphereEraser is actively placing (left button held)
|
|
74318
|
+
if ((this.ctx.gui_states.mode.sphereBrush || this.ctx.gui_states.mode.sphereEraser)
|
|
74319
|
+
&& ((_b = this.ctx.eventRouter) === null || _b === void 0 ? void 0 : _b.isLeftButtonDown())) {
|
|
74320
|
+
return;
|
|
74321
|
+
}
|
|
74311
74322
|
e.preventDefault();
|
|
74312
74323
|
const delta = e.detail ? e.detail > 0 : e.wheelDelta < 0;
|
|
74313
74324
|
this.ctx.protectedData.isDrawing = true;
|
|
@@ -74909,6 +74920,493 @@ void main() {
|
|
|
74909
74920
|
}
|
|
74910
74921
|
}
|
|
74911
74922
|
|
|
74923
|
+
/**
|
|
74924
|
+
* SphereBrushTool — 3D sphere brush and eraser on layer MaskVolume.
|
|
74925
|
+
*
|
|
74926
|
+
* Unlike SphereTool (which uses its own isolated sphereMaskVolume),
|
|
74927
|
+
* this tool writes/erases directly in the active layer's shared MaskVolume —
|
|
74928
|
+
* the same volume used by pencil/brush/eraser tools.
|
|
74929
|
+
*
|
|
74930
|
+
* Two operation modes handled by one class:
|
|
74931
|
+
* - **sphereBrush**: Direct click to paint a 3D sphere of the active channel label.
|
|
74932
|
+
* - **sphereEraser**: Shift+click to erase a 3D sphere (only voxels matching active channel).
|
|
74933
|
+
*
|
|
74934
|
+
* Interaction:
|
|
74935
|
+
* 1. Pointer-down records sphere center, switches wheel to radius mode.
|
|
74936
|
+
* 2. Scroll wheel adjusts sphereBrushRadius [1, 50].
|
|
74937
|
+
* 3. Pointer-up writes/erases sphere to volume, captures grouped undo, refreshes canvas.
|
|
74938
|
+
*/
|
|
74939
|
+
class SphereBrushTool extends BaseTool {
|
|
74940
|
+
constructor(ctx, callbacks) {
|
|
74941
|
+
super(ctx);
|
|
74942
|
+
/** Recorded sphere center in canvas mm-space */
|
|
74943
|
+
this.centerX = 0;
|
|
74944
|
+
this.centerY = 0;
|
|
74945
|
+
this.centerSlice = 0;
|
|
74946
|
+
/** Whether a sphere placement is in progress */
|
|
74947
|
+
this.active = false;
|
|
74948
|
+
/** Current operation mode for the active placement */
|
|
74949
|
+
this.mode = "brush";
|
|
74950
|
+
/** Cumulative "before" snapshots for drag-erase undo (z-index → slice data) */
|
|
74951
|
+
this.dragBeforeSnapshots = new Map();
|
|
74952
|
+
/** Whether a drag-erase has actually moved (vs. simple click-release) */
|
|
74953
|
+
this.dragMoved = false;
|
|
74954
|
+
this.callbacks = callbacks;
|
|
74955
|
+
}
|
|
74956
|
+
setCallbacks(callbacks) {
|
|
74957
|
+
this.callbacks = callbacks;
|
|
74958
|
+
}
|
|
74959
|
+
// ── Geometry (ported from SphereTool) ──────────────────────────
|
|
74960
|
+
/**
|
|
74961
|
+
* Convert canvas mm-space coordinates to 3D voxel coordinates.
|
|
74962
|
+
*/
|
|
74963
|
+
canvasToVoxelCenter(canvasX, canvasY, sliceIndex, axis) {
|
|
74964
|
+
const nrrd = this.ctx.nrrd_states;
|
|
74965
|
+
switch (axis) {
|
|
74966
|
+
case 'z':
|
|
74967
|
+
return {
|
|
74968
|
+
x: canvasX * nrrd.image.nrrd_x_pixel / nrrd.image.nrrd_x_mm,
|
|
74969
|
+
y: canvasY * nrrd.image.nrrd_y_pixel / nrrd.image.nrrd_y_mm,
|
|
74970
|
+
z: sliceIndex,
|
|
74971
|
+
};
|
|
74972
|
+
case 'y':
|
|
74973
|
+
return {
|
|
74974
|
+
x: canvasX * nrrd.image.nrrd_x_pixel / nrrd.image.nrrd_x_mm,
|
|
74975
|
+
y: sliceIndex,
|
|
74976
|
+
z: (nrrd.image.nrrd_z_mm - canvasY) * nrrd.image.nrrd_z_pixel / nrrd.image.nrrd_z_mm,
|
|
74977
|
+
};
|
|
74978
|
+
case 'x':
|
|
74979
|
+
return {
|
|
74980
|
+
x: sliceIndex,
|
|
74981
|
+
y: canvasY * nrrd.image.nrrd_y_pixel / nrrd.image.nrrd_y_mm,
|
|
74982
|
+
z: canvasX * nrrd.image.nrrd_z_pixel / nrrd.image.nrrd_z_mm,
|
|
74983
|
+
};
|
|
74984
|
+
}
|
|
74985
|
+
}
|
|
74986
|
+
/**
|
|
74987
|
+
* Convert mm radius to per-axis voxel radii.
|
|
74988
|
+
*/
|
|
74989
|
+
getVoxelRadii(radius) {
|
|
74990
|
+
const nrrd = this.ctx.nrrd_states;
|
|
74991
|
+
return {
|
|
74992
|
+
rx: radius * nrrd.image.nrrd_x_pixel / nrrd.image.nrrd_x_mm,
|
|
74993
|
+
ry: radius * nrrd.image.nrrd_y_pixel / nrrd.image.nrrd_y_mm,
|
|
74994
|
+
rz: radius * nrrd.image.nrrd_z_pixel / nrrd.image.nrrd_z_mm,
|
|
74995
|
+
};
|
|
74996
|
+
}
|
|
74997
|
+
/**
|
|
74998
|
+
* Compute clamped bounding box for an ellipsoid in voxel space.
|
|
74999
|
+
*/
|
|
75000
|
+
getBoundingBox(center, radii, dims) {
|
|
75001
|
+
return {
|
|
75002
|
+
minX: Math.max(0, Math.floor(center.x - radii.rx)),
|
|
75003
|
+
maxX: Math.min(dims.width - 1, Math.ceil(center.x + radii.rx)),
|
|
75004
|
+
minY: Math.max(0, Math.floor(center.y - radii.ry)),
|
|
75005
|
+
maxY: Math.min(dims.height - 1, Math.ceil(center.y + radii.ry)),
|
|
75006
|
+
minZ: Math.max(0, Math.floor(center.z - radii.rz)),
|
|
75007
|
+
maxZ: Math.min(dims.depth - 1, Math.ceil(center.z + radii.rz)),
|
|
75008
|
+
};
|
|
75009
|
+
}
|
|
75010
|
+
// ── 3D Volume Write / Erase ────────────────────────────────────
|
|
75011
|
+
/**
|
|
75012
|
+
* Write a 3D sphere of the active channel label into the layer's MaskVolume.
|
|
75013
|
+
*/
|
|
75014
|
+
write3DSphereToBrush(vol, center, radii, bb, label) {
|
|
75015
|
+
const { rx, ry, rz } = radii;
|
|
75016
|
+
for (let z = bb.minZ; z <= bb.maxZ; z++) {
|
|
75017
|
+
for (let y = bb.minY; y <= bb.maxY; y++) {
|
|
75018
|
+
for (let x = bb.minX; x <= bb.maxX; x++) {
|
|
75019
|
+
const dx = rx > 0 ? (x - center.x) / rx : 0;
|
|
75020
|
+
const dy = ry > 0 ? (y - center.y) / ry : 0;
|
|
75021
|
+
const dz = rz > 0 ? (z - center.z) / rz : 0;
|
|
75022
|
+
if (dx * dx + dy * dy + dz * dz <= 1.0) {
|
|
75023
|
+
vol.setVoxel(x, y, z, label);
|
|
75024
|
+
}
|
|
75025
|
+
}
|
|
75026
|
+
}
|
|
75027
|
+
}
|
|
75028
|
+
}
|
|
75029
|
+
/**
|
|
75030
|
+
* Erase a 3D sphere from the layer's MaskVolume.
|
|
75031
|
+
* Only clears voxels whose current value equals the active channel label.
|
|
75032
|
+
*/
|
|
75033
|
+
erase3DSphereFromVolume(vol, center, radii, bb, label) {
|
|
75034
|
+
const { rx, ry, rz } = radii;
|
|
75035
|
+
for (let z = bb.minZ; z <= bb.maxZ; z++) {
|
|
75036
|
+
for (let y = bb.minY; y <= bb.maxY; y++) {
|
|
75037
|
+
for (let x = bb.minX; x <= bb.maxX; x++) {
|
|
75038
|
+
const dx = rx > 0 ? (x - center.x) / rx : 0;
|
|
75039
|
+
const dy = ry > 0 ? (y - center.y) / ry : 0;
|
|
75040
|
+
const dz = rz > 0 ? (z - center.z) / rz : 0;
|
|
75041
|
+
if (dx * dx + dy * dy + dz * dz <= 1.0) {
|
|
75042
|
+
if (vol.getVoxel(x, y, z) === label) {
|
|
75043
|
+
vol.setVoxel(x, y, z, 0);
|
|
75044
|
+
}
|
|
75045
|
+
}
|
|
75046
|
+
}
|
|
75047
|
+
}
|
|
75048
|
+
}
|
|
75049
|
+
}
|
|
75050
|
+
// ── Undo Capture ───────────────────────────────────────────────
|
|
75051
|
+
/**
|
|
75052
|
+
* Capture slice snapshots for all Z-slices in the bounding box.
|
|
75053
|
+
* Used before and after the sphere operation to build MaskDelta groups.
|
|
75054
|
+
*
|
|
75055
|
+
* We always capture along the Z axis since the 3D sphere spans Z slices.
|
|
75056
|
+
*/
|
|
75057
|
+
captureSliceSnapshots(vol, minZ, maxZ) {
|
|
75058
|
+
const snapshots = new Map();
|
|
75059
|
+
for (let z = minZ; z <= maxZ; z++) {
|
|
75060
|
+
try {
|
|
75061
|
+
const { data } = vol.getSliceUint8(z, 'z');
|
|
75062
|
+
snapshots.set(z, data.slice());
|
|
75063
|
+
}
|
|
75064
|
+
catch (_a) {
|
|
75065
|
+
// slice out of bounds — skip
|
|
75066
|
+
}
|
|
75067
|
+
}
|
|
75068
|
+
return snapshots;
|
|
75069
|
+
}
|
|
75070
|
+
/**
|
|
75071
|
+
* Build MaskDelta group from before/after snapshots.
|
|
75072
|
+
*/
|
|
75073
|
+
buildUndoGroup(layerId, before, after) {
|
|
75074
|
+
const deltas = [];
|
|
75075
|
+
for (const [sliceIndex, oldSlice] of before) {
|
|
75076
|
+
const newSlice = after.get(sliceIndex);
|
|
75077
|
+
if (!newSlice)
|
|
75078
|
+
continue;
|
|
75079
|
+
// Only include slices that actually changed
|
|
75080
|
+
let changed = false;
|
|
75081
|
+
for (let i = 0; i < oldSlice.length; i++) {
|
|
75082
|
+
if (oldSlice[i] !== newSlice[i]) {
|
|
75083
|
+
changed = true;
|
|
75084
|
+
break;
|
|
75085
|
+
}
|
|
75086
|
+
}
|
|
75087
|
+
if (changed) {
|
|
75088
|
+
deltas.push({ layerId, axis: 'z', sliceIndex, oldSlice, newSlice });
|
|
75089
|
+
}
|
|
75090
|
+
}
|
|
75091
|
+
return deltas;
|
|
75092
|
+
}
|
|
75093
|
+
// ── Preview Rendering ──────────────────────────────────────────
|
|
75094
|
+
/**
|
|
75095
|
+
* Draw a preview circle on the sphere canvas during sphere placement.
|
|
75096
|
+
*/
|
|
75097
|
+
drawPreview(mouseX, mouseY, radius, isEraser) {
|
|
75098
|
+
const sphereCanvas = this.ctx.protectedData.canvases.drawingSphereCanvas;
|
|
75099
|
+
const sphereCtx = this.ctx.protectedData.ctxes.drawingSphereCtx;
|
|
75100
|
+
// Clear and resize sphere canvas
|
|
75101
|
+
sphereCanvas.width = this.ctx.protectedData.canvases.originCanvas.width;
|
|
75102
|
+
sphereCanvas.height = this.ctx.protectedData.canvases.originCanvas.height;
|
|
75103
|
+
sphereCtx.beginPath();
|
|
75104
|
+
sphereCtx.arc(mouseX, mouseY, radius, 0, 2 * Math.PI);
|
|
75105
|
+
if (isEraser) {
|
|
75106
|
+
// Eraser preview: dashed outline
|
|
75107
|
+
sphereCtx.strokeStyle = "#ff4444";
|
|
75108
|
+
sphereCtx.lineWidth = 2;
|
|
75109
|
+
sphereCtx.setLineDash([4, 4]);
|
|
75110
|
+
sphereCtx.stroke();
|
|
75111
|
+
sphereCtx.setLineDash([]);
|
|
75112
|
+
}
|
|
75113
|
+
else {
|
|
75114
|
+
// Brush preview: semi-transparent fill with active channel color
|
|
75115
|
+
const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
|
|
75116
|
+
const color = this.getActiveChannelColor(channel);
|
|
75117
|
+
sphereCtx.fillStyle = color;
|
|
75118
|
+
sphereCtx.globalAlpha = 0.5;
|
|
75119
|
+
sphereCtx.fill();
|
|
75120
|
+
sphereCtx.globalAlpha = 1.0;
|
|
75121
|
+
}
|
|
75122
|
+
sphereCtx.closePath();
|
|
75123
|
+
}
|
|
75124
|
+
/**
|
|
75125
|
+
* Clear the sphere preview canvas.
|
|
75126
|
+
*/
|
|
75127
|
+
clearPreview() {
|
|
75128
|
+
const sphereCanvas = this.ctx.protectedData.canvases.drawingSphereCanvas;
|
|
75129
|
+
sphereCanvas.width = sphereCanvas.width;
|
|
75130
|
+
}
|
|
75131
|
+
/**
|
|
75132
|
+
* Get the hex color for the active channel from the layer's MaskVolume.
|
|
75133
|
+
*/
|
|
75134
|
+
getActiveChannelColor(channel) {
|
|
75135
|
+
var _a;
|
|
75136
|
+
const layer = this.ctx.gui_states.layerChannel.layer;
|
|
75137
|
+
const volumes = this.ctx.protectedData.maskData.volumes;
|
|
75138
|
+
const vol = volumes[layer];
|
|
75139
|
+
if (vol) {
|
|
75140
|
+
const rgba = vol.getChannelColor(channel);
|
|
75141
|
+
const r = rgba.r.toString(16).padStart(2, '0');
|
|
75142
|
+
const g = rgba.g.toString(16).padStart(2, '0');
|
|
75143
|
+
const b = rgba.b.toString(16).padStart(2, '0');
|
|
75144
|
+
return `#${r}${g}${b}`;
|
|
75145
|
+
}
|
|
75146
|
+
return (_a = CHANNEL_HEX_COLORS[channel]) !== null && _a !== void 0 ? _a : "#ff0000";
|
|
75147
|
+
}
|
|
75148
|
+
// ── Wheel Handler ──────────────────────────────────────────────
|
|
75149
|
+
/**
|
|
75150
|
+
* Returns a wheel event handler for adjusting sphereBrushRadius.
|
|
75151
|
+
* Used in both sphereBrush and sphereEraser modes.
|
|
75152
|
+
*/
|
|
75153
|
+
configSphereBrushWheel() {
|
|
75154
|
+
return (e) => {
|
|
75155
|
+
e.preventDefault();
|
|
75156
|
+
const sphere = this.ctx.nrrd_states.sphere;
|
|
75157
|
+
if (e.deltaY < 0) {
|
|
75158
|
+
sphere.sphereBrushRadius += 1;
|
|
75159
|
+
}
|
|
75160
|
+
else {
|
|
75161
|
+
sphere.sphereBrushRadius -= 1;
|
|
75162
|
+
}
|
|
75163
|
+
sphere.sphereBrushRadius = Math.max(1, Math.min(sphere.sphereBrushRadius, 50));
|
|
75164
|
+
// Redraw preview
|
|
75165
|
+
if (this.active) {
|
|
75166
|
+
this.drawPreview(this.centerX, this.centerY, sphere.sphereBrushRadius, this.mode === "eraser");
|
|
75167
|
+
}
|
|
75168
|
+
};
|
|
75169
|
+
}
|
|
75170
|
+
// ── Sphere Brush Click/PointerUp ───────────────────────────────
|
|
75171
|
+
/**
|
|
75172
|
+
* Handle pointer-down in sphereBrush mode (direct click, no Shift needed).
|
|
75173
|
+
*/
|
|
75174
|
+
onSphereBrushClick(e) {
|
|
75175
|
+
this.centerX = e.offsetX / this.ctx.nrrd_states.view.sizeFactor;
|
|
75176
|
+
this.centerY = e.offsetY / this.ctx.nrrd_states.view.sizeFactor;
|
|
75177
|
+
this.centerSlice = this.ctx.nrrd_states.view.currentSliceIndex;
|
|
75178
|
+
this.active = true;
|
|
75179
|
+
this.mode = "brush";
|
|
75180
|
+
this.drawPreview(this.centerX, this.centerY, this.ctx.nrrd_states.sphere.sphereBrushRadius, false);
|
|
75181
|
+
}
|
|
75182
|
+
/**
|
|
75183
|
+
* Handle pointer-up in sphereBrush mode — write sphere to volume.
|
|
75184
|
+
*/
|
|
75185
|
+
onSphereBrushPointerUp() {
|
|
75186
|
+
if (!this.active || this.mode !== "brush")
|
|
75187
|
+
return;
|
|
75188
|
+
this.active = false;
|
|
75189
|
+
const layer = this.ctx.gui_states.layerChannel.layer;
|
|
75190
|
+
const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
|
|
75191
|
+
const axis = this.ctx.protectedData.axis;
|
|
75192
|
+
const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
|
|
75193
|
+
let vol;
|
|
75194
|
+
try {
|
|
75195
|
+
vol = this.callbacks.getVolumeForLayer(layer);
|
|
75196
|
+
}
|
|
75197
|
+
catch (_a) {
|
|
75198
|
+
this.clearPreview();
|
|
75199
|
+
return;
|
|
75200
|
+
}
|
|
75201
|
+
const dims = vol.getDimensions();
|
|
75202
|
+
const center = this.canvasToVoxelCenter(this.centerX, this.centerY, this.centerSlice, axis);
|
|
75203
|
+
const radii = this.getVoxelRadii(radius);
|
|
75204
|
+
const bb = this.getBoundingBox(center, radii, dims);
|
|
75205
|
+
// Capture pre-write snapshots
|
|
75206
|
+
const before = this.captureSliceSnapshots(vol, bb.minZ, bb.maxZ);
|
|
75207
|
+
// Write sphere
|
|
75208
|
+
this.write3DSphereToBrush(vol, center, radii, bb, channel);
|
|
75209
|
+
// Capture post-write snapshots and push undo group
|
|
75210
|
+
const after = this.captureSliceSnapshots(vol, bb.minZ, bb.maxZ);
|
|
75211
|
+
const deltas = this.buildUndoGroup(layer, before, after);
|
|
75212
|
+
if (deltas.length > 0) {
|
|
75213
|
+
this.callbacks.pushUndoGroup(deltas);
|
|
75214
|
+
}
|
|
75215
|
+
// Refresh display and notify backend of ALL changed slices
|
|
75216
|
+
this.refreshDisplay(layer, deltas);
|
|
75217
|
+
this.clearPreview();
|
|
75218
|
+
}
|
|
75219
|
+
// ── Sphere Eraser Click/PointerUp ──────────────────────────────
|
|
75220
|
+
/**
|
|
75221
|
+
* Handle pointer-down in sphereEraser mode.
|
|
75222
|
+
* Initializes drag tracking for cumulative undo.
|
|
75223
|
+
*/
|
|
75224
|
+
onSphereEraserClick(e) {
|
|
75225
|
+
this.centerX = e.offsetX / this.ctx.nrrd_states.view.sizeFactor;
|
|
75226
|
+
this.centerY = e.offsetY / this.ctx.nrrd_states.view.sizeFactor;
|
|
75227
|
+
this.centerSlice = this.ctx.nrrd_states.view.currentSliceIndex;
|
|
75228
|
+
this.active = true;
|
|
75229
|
+
this.mode = "eraser";
|
|
75230
|
+
this.dragMoved = false;
|
|
75231
|
+
this.dragBeforeSnapshots.clear();
|
|
75232
|
+
// Capture initial "before" snapshots for the first sphere position
|
|
75233
|
+
const layer = this.ctx.gui_states.layerChannel.layer;
|
|
75234
|
+
const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
|
|
75235
|
+
try {
|
|
75236
|
+
const vol = this.callbacks.getVolumeForLayer(layer);
|
|
75237
|
+
const dims = vol.getDimensions();
|
|
75238
|
+
const axis = this.ctx.protectedData.axis;
|
|
75239
|
+
const center = this.canvasToVoxelCenter(this.centerX, this.centerY, this.centerSlice, axis);
|
|
75240
|
+
const radii = this.getVoxelRadii(radius);
|
|
75241
|
+
const bb = this.getBoundingBox(center, radii, dims);
|
|
75242
|
+
this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
|
|
75243
|
+
}
|
|
75244
|
+
catch (_a) {
|
|
75245
|
+
// Volume not ready
|
|
75246
|
+
}
|
|
75247
|
+
this.drawPreview(this.centerX, this.centerY, radius, true);
|
|
75248
|
+
}
|
|
75249
|
+
/**
|
|
75250
|
+
* Handle pointer-move in sphereEraser mode — drag to continuously erase.
|
|
75251
|
+
*/
|
|
75252
|
+
onSphereEraserMove(e) {
|
|
75253
|
+
if (!this.active || this.mode !== "eraser")
|
|
75254
|
+
return;
|
|
75255
|
+
this.dragMoved = true;
|
|
75256
|
+
const mouseX = e.offsetX / this.ctx.nrrd_states.view.sizeFactor;
|
|
75257
|
+
const mouseY = e.offsetY / this.ctx.nrrd_states.view.sizeFactor;
|
|
75258
|
+
const sliceIndex = this.ctx.nrrd_states.view.currentSliceIndex;
|
|
75259
|
+
const layer = this.ctx.gui_states.layerChannel.layer;
|
|
75260
|
+
const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
|
|
75261
|
+
const axis = this.ctx.protectedData.axis;
|
|
75262
|
+
const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
|
|
75263
|
+
let vol;
|
|
75264
|
+
try {
|
|
75265
|
+
vol = this.callbacks.getVolumeForLayer(layer);
|
|
75266
|
+
}
|
|
75267
|
+
catch (_a) {
|
|
75268
|
+
return;
|
|
75269
|
+
}
|
|
75270
|
+
const dims = vol.getDimensions();
|
|
75271
|
+
const center = this.canvasToVoxelCenter(mouseX, mouseY, sliceIndex, axis);
|
|
75272
|
+
const radii = this.getVoxelRadii(radius);
|
|
75273
|
+
const bb = this.getBoundingBox(center, radii, dims);
|
|
75274
|
+
// Expand "before" snapshots to cover any new Z slices
|
|
75275
|
+
this.expandDragBeforeSnapshots(vol, bb.minZ, bb.maxZ);
|
|
75276
|
+
// Erase sphere at current position
|
|
75277
|
+
this.erase3DSphereFromVolume(vol, center, radii, bb, channel);
|
|
75278
|
+
// Update preview position
|
|
75279
|
+
this.drawPreview(mouseX, mouseY, radius, true);
|
|
75280
|
+
// Refresh display so user sees the erase in real-time
|
|
75281
|
+
this.refreshDisplay(layer);
|
|
75282
|
+
}
|
|
75283
|
+
/**
|
|
75284
|
+
* Handle pointer-up in sphereEraser mode — finalize erase + push undo group.
|
|
75285
|
+
* Works for both click-release and drag-release.
|
|
75286
|
+
*/
|
|
75287
|
+
onSphereEraserPointerUp() {
|
|
75288
|
+
if (!this.active || this.mode !== "eraser")
|
|
75289
|
+
return;
|
|
75290
|
+
this.active = false;
|
|
75291
|
+
const layer = this.ctx.gui_states.layerChannel.layer;
|
|
75292
|
+
const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
|
|
75293
|
+
const axis = this.ctx.protectedData.axis;
|
|
75294
|
+
const radius = this.ctx.nrrd_states.sphere.sphereBrushRadius;
|
|
75295
|
+
let vol;
|
|
75296
|
+
try {
|
|
75297
|
+
vol = this.callbacks.getVolumeForLayer(layer);
|
|
75298
|
+
}
|
|
75299
|
+
catch (_a) {
|
|
75300
|
+
this.clearPreview();
|
|
75301
|
+
this.dragBeforeSnapshots.clear();
|
|
75302
|
+
return;
|
|
75303
|
+
}
|
|
75304
|
+
// If no drag occurred, erase at the click position (original click-release behavior)
|
|
75305
|
+
if (!this.dragMoved) {
|
|
75306
|
+
const dims = vol.getDimensions();
|
|
75307
|
+
const center = this.canvasToVoxelCenter(this.centerX, this.centerY, this.centerSlice, axis);
|
|
75308
|
+
const radii = this.getVoxelRadii(radius);
|
|
75309
|
+
const bb = this.getBoundingBox(center, radii, dims);
|
|
75310
|
+
this.erase3DSphereFromVolume(vol, center, radii, bb, channel);
|
|
75311
|
+
}
|
|
75312
|
+
// Build undo group from cumulative "before" snapshots vs current state
|
|
75313
|
+
const after = this.captureSliceSnapshots(vol, this.dragMinZ(), this.dragMaxZ());
|
|
75314
|
+
const deltas = this.buildUndoGroup(layer, this.dragBeforeSnapshots, after);
|
|
75315
|
+
if (deltas.length > 0) {
|
|
75316
|
+
this.callbacks.pushUndoGroup(deltas);
|
|
75317
|
+
}
|
|
75318
|
+
// Refresh display and notify backend of ALL changed slices
|
|
75319
|
+
this.refreshDisplay(layer, deltas);
|
|
75320
|
+
this.clearPreview();
|
|
75321
|
+
this.dragBeforeSnapshots.clear();
|
|
75322
|
+
}
|
|
75323
|
+
/**
|
|
75324
|
+
* Expand cumulative "before" snapshots to cover z range [minZ, maxZ].
|
|
75325
|
+
* Only captures slices not yet in the map (preserves original state).
|
|
75326
|
+
*/
|
|
75327
|
+
expandDragBeforeSnapshots(vol, minZ, maxZ) {
|
|
75328
|
+
for (let z = minZ; z <= maxZ; z++) {
|
|
75329
|
+
if (!this.dragBeforeSnapshots.has(z)) {
|
|
75330
|
+
try {
|
|
75331
|
+
const { data } = vol.getSliceUint8(z, 'z');
|
|
75332
|
+
this.dragBeforeSnapshots.set(z, data.slice());
|
|
75333
|
+
}
|
|
75334
|
+
catch (_a) {
|
|
75335
|
+
// slice out of bounds
|
|
75336
|
+
}
|
|
75337
|
+
}
|
|
75338
|
+
}
|
|
75339
|
+
}
|
|
75340
|
+
/** Get minimum Z index from drag snapshots */
|
|
75341
|
+
dragMinZ() {
|
|
75342
|
+
let min = Infinity;
|
|
75343
|
+
for (const z of this.dragBeforeSnapshots.keys()) {
|
|
75344
|
+
if (z < min)
|
|
75345
|
+
min = z;
|
|
75346
|
+
}
|
|
75347
|
+
return min === Infinity ? 0 : min;
|
|
75348
|
+
}
|
|
75349
|
+
/** Get maximum Z index from drag snapshots */
|
|
75350
|
+
dragMaxZ() {
|
|
75351
|
+
let max = -Infinity;
|
|
75352
|
+
for (const z of this.dragBeforeSnapshots.keys()) {
|
|
75353
|
+
if (z > max)
|
|
75354
|
+
max = z;
|
|
75355
|
+
}
|
|
75356
|
+
return max === -Infinity ? 0 : max;
|
|
75357
|
+
}
|
|
75358
|
+
// ── Post-write Display Refresh ─────────────────────────────────
|
|
75359
|
+
/**
|
|
75360
|
+
* After writing/erasing the sphere in the volume, re-render the current
|
|
75361
|
+
* slice's layer canvas and composite all layers to master.
|
|
75362
|
+
*
|
|
75363
|
+
* @param changedDeltas - If provided, fire onMaskChanged for ALL changed
|
|
75364
|
+
* slices (not just the current view slice) so the backend receives the
|
|
75365
|
+
* full 3D sphere data for NII/GLTF export.
|
|
75366
|
+
*/
|
|
75367
|
+
refreshDisplay(layerId, changedDeltas) {
|
|
75368
|
+
// Re-render layer canvas from volume for the current slice
|
|
75369
|
+
const target = this.ctx.protectedData.layerTargets.get(layerId);
|
|
75370
|
+
if (target) {
|
|
75371
|
+
const { ctx, canvas } = target;
|
|
75372
|
+
canvas.width = canvas.width; // clear
|
|
75373
|
+
const buffer = this.callbacks.getOrCreateSliceBuffer(this.ctx.protectedData.axis);
|
|
75374
|
+
if (buffer) {
|
|
75375
|
+
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);
|
|
75376
|
+
}
|
|
75377
|
+
}
|
|
75378
|
+
// Composite all layers to master canvas
|
|
75379
|
+
this.callbacks.compositeAllLayers();
|
|
75380
|
+
// Fire onMaskChanged for ALL changed slices (not just the current view slice)
|
|
75381
|
+
// This ensures the backend receives the full 3D sphere data for export.
|
|
75382
|
+
try {
|
|
75383
|
+
const vol = this.callbacks.getVolumeForLayer(layerId);
|
|
75384
|
+
const channel = this.ctx.gui_states.layerChannel.activeChannel || 1;
|
|
75385
|
+
if (changedDeltas && changedDeltas.length > 0) {
|
|
75386
|
+
// Report every changed Z slice
|
|
75387
|
+
for (const delta of changedDeltas) {
|
|
75388
|
+
const { data: sliceData, width, height } = vol.getSliceUint8(delta.sliceIndex, delta.axis);
|
|
75389
|
+
this.ctx.callbacks.onMaskChanged(sliceData, layerId, channel, delta.sliceIndex, delta.axis, width, height, false);
|
|
75390
|
+
}
|
|
75391
|
+
}
|
|
75392
|
+
else {
|
|
75393
|
+
// Fallback: report only the current slice
|
|
75394
|
+
const axis = this.ctx.protectedData.axis;
|
|
75395
|
+
const sliceIndex = this.ctx.nrrd_states.view.currentSliceIndex;
|
|
75396
|
+
const { data: sliceData, width, height } = vol.getSliceUint8(sliceIndex, axis);
|
|
75397
|
+
this.ctx.callbacks.onMaskChanged(sliceData, layerId, channel, sliceIndex, axis, width, height, false);
|
|
75398
|
+
}
|
|
75399
|
+
}
|
|
75400
|
+
catch (_a) {
|
|
75401
|
+
// Volume not ready
|
|
75402
|
+
}
|
|
75403
|
+
}
|
|
75404
|
+
/** Whether a sphere placement is currently in progress */
|
|
75405
|
+
get isActive() {
|
|
75406
|
+
return this.active;
|
|
75407
|
+
}
|
|
75408
|
+
}
|
|
75409
|
+
|
|
74912
75410
|
/**
|
|
74913
75411
|
* DrawToolCore — Tool orchestration and event routing.
|
|
74914
75412
|
*
|
|
@@ -74929,6 +75427,7 @@ void main() {
|
|
|
74929
75427
|
handleOnDrawingBrushCricleMove: (ev) => { },
|
|
74930
75428
|
handleMouseZoomSliceWheel: (e) => { },
|
|
74931
75429
|
handleSphereWheel: (e) => { },
|
|
75430
|
+
handleSphereBrushWheel: (e) => { },
|
|
74932
75431
|
};
|
|
74933
75432
|
this.contrastEventPrameters = {
|
|
74934
75433
|
move_x: 0,
|
|
@@ -75011,6 +75510,16 @@ void main() {
|
|
|
75011
75510
|
pushUndoDelta: (delta) => this.undoManager.push(delta),
|
|
75012
75511
|
getEraserUrls: () => this.eraserUrls,
|
|
75013
75512
|
});
|
|
75513
|
+
this.sphereBrushTool = new SphereBrushTool(toolCtx, {
|
|
75514
|
+
getVolumeForLayer: (layer) => this.renderer.getVolumeForLayer(layer),
|
|
75515
|
+
compositeAllLayers: () => this.renderer.compositeAllLayers(),
|
|
75516
|
+
pushUndoGroup: (deltas) => this.undoManager.pushGroup(deltas),
|
|
75517
|
+
renderSliceToCanvas: (layer, axis, sliceIndex, buffer, ctx, w, h) => this.renderer.renderSliceToCanvas(layer, axis, sliceIndex, buffer, ctx, w, h),
|
|
75518
|
+
getOrCreateSliceBuffer: (axis) => this.renderer.getOrCreateSliceBuffer(axis),
|
|
75519
|
+
setEmptyCanvasSize: (axis) => this.setEmptyCanvasSize(axis),
|
|
75520
|
+
reloadMasksFromVolume: () => this.reloadMasksFromVolume(),
|
|
75521
|
+
getEraserUrls: () => this.eraserUrls,
|
|
75522
|
+
});
|
|
75014
75523
|
}
|
|
75015
75524
|
initDrawToolCore() {
|
|
75016
75525
|
// Initialize EventRouter for centralized event handling
|
|
@@ -75055,12 +75564,28 @@ void main() {
|
|
|
75055
75564
|
if ((ev.ctrlKey || ev.metaKey) && (ev.key === redoKey || (ev.shiftKey && ev.key === undoKeyUpper))) {
|
|
75056
75565
|
this.redoLastPainting();
|
|
75057
75566
|
}
|
|
75567
|
+
// Handle mouse wheel mode toggle (Ctrl+1 = Scroll:Zoom, Ctrl+2 = Scroll:Slice)
|
|
75568
|
+
if ((ev.ctrlKey || ev.metaKey) && ev.key === '1') {
|
|
75569
|
+
ev.preventDefault();
|
|
75570
|
+
this.state.keyboardSettings.mouseWheel = 'Scroll:Zoom';
|
|
75571
|
+
this.updateMouseWheelEvent();
|
|
75572
|
+
return;
|
|
75573
|
+
}
|
|
75574
|
+
if ((ev.ctrlKey || ev.metaKey) && ev.key === '2') {
|
|
75575
|
+
ev.preventDefault();
|
|
75576
|
+
this.state.keyboardSettings.mouseWheel = 'Scroll:Slice';
|
|
75577
|
+
this.updateMouseWheelEvent();
|
|
75578
|
+
return;
|
|
75579
|
+
}
|
|
75058
75580
|
// Handle crosshair toggle (allowed in drawing tools AND sphere mode)
|
|
75059
75581
|
if (ev.key === this.state.keyboardSettings.crosshair) {
|
|
75060
75582
|
this.eventRouter.toggleCrosshair();
|
|
75061
75583
|
}
|
|
75062
75584
|
// Handle draw mode (Shift key) - EventRouter already tracks this
|
|
75063
|
-
if (ev.key === this.state.keyboardSettings.draw
|
|
75585
|
+
if (ev.key === this.state.keyboardSettings.draw
|
|
75586
|
+
&& !this.state.gui_states.mode.sphere
|
|
75587
|
+
&& !this.state.gui_states.mode.sphereBrush
|
|
75588
|
+
&& !this.state.gui_states.mode.sphereEraser) {
|
|
75064
75589
|
if (this.eventRouter.isCtrlHeld()) {
|
|
75065
75590
|
return; // Ctrl takes priority
|
|
75066
75591
|
}
|
|
@@ -75112,11 +75637,16 @@ void main() {
|
|
|
75112
75637
|
if (this.drawingTool.isActive || this.panTool.isActive) {
|
|
75113
75638
|
this.drawingPrameters.handleOnDrawingMouseMove(e);
|
|
75114
75639
|
}
|
|
75640
|
+
// Drag-erase: route move to sphereBrushTool when sphereEraser is active
|
|
75641
|
+
if (this.sphereBrushTool.isActive && this.state.gui_states.mode.sphereEraser) {
|
|
75642
|
+
this.sphereBrushTool.onSphereEraserMove(e);
|
|
75643
|
+
}
|
|
75115
75644
|
});
|
|
75116
75645
|
this.eventRouter.setPointerUpHandler((e) => {
|
|
75117
75646
|
if (this.drawingTool.isActive || this.drawingTool.painting
|
|
75118
75647
|
|| this.panTool.isActive
|
|
75119
|
-
|| (this.state.gui_states.mode.sphere && this.eventRouter.getMode() !== 'crosshair')
|
|
75648
|
+
|| (this.state.gui_states.mode.sphere && this.eventRouter.getMode() !== 'crosshair')
|
|
75649
|
+
|| this.sphereBrushTool.isActive) {
|
|
75120
75650
|
this.drawingPrameters.handleOnDrawingMouseUp(e);
|
|
75121
75651
|
}
|
|
75122
75652
|
});
|
|
@@ -75131,6 +75661,9 @@ void main() {
|
|
|
75131
75661
|
else if (this.activeWheelMode === 'sphere') {
|
|
75132
75662
|
this.drawingPrameters.handleSphereWheel(e);
|
|
75133
75663
|
}
|
|
75664
|
+
else if (this.activeWheelMode === 'sphereBrush') {
|
|
75665
|
+
this.drawingPrameters.handleSphereBrushWheel(e);
|
|
75666
|
+
}
|
|
75134
75667
|
});
|
|
75135
75668
|
// Bind all event listeners
|
|
75136
75669
|
this.eventRouter.bindAll();
|
|
@@ -75194,6 +75727,8 @@ void main() {
|
|
|
75194
75727
|
}
|
|
75195
75728
|
// sphere Wheel
|
|
75196
75729
|
this.drawingPrameters.handleSphereWheel = this.configMouseSphereWheel();
|
|
75730
|
+
// sphere brush Wheel
|
|
75731
|
+
this.drawingPrameters.handleSphereBrushWheel = this.sphereBrushTool.configSphereBrushWheel();
|
|
75197
75732
|
// brush circle move — delegated to DrawingTool
|
|
75198
75733
|
this.drawingPrameters.handleOnDrawingBrushCricleMove =
|
|
75199
75734
|
this.drawingTool.createBrushTrackingHandler();
|
|
@@ -75224,6 +75759,16 @@ void main() {
|
|
|
75224
75759
|
e.offsetY / this.state.nrrd_states.view.sizeFactor;
|
|
75225
75760
|
this.enableCrosshair();
|
|
75226
75761
|
}
|
|
75762
|
+
else if (this.state.gui_states.mode.sphereBrush && !this.eventRouter.isCrosshairEnabled()) {
|
|
75763
|
+
// SphereBrush: direct click (no Shift needed)
|
|
75764
|
+
this.activeWheelMode = 'sphereBrush';
|
|
75765
|
+
this.sphereBrushTool.onSphereBrushClick(e);
|
|
75766
|
+
}
|
|
75767
|
+
else if (this.state.gui_states.mode.sphereEraser && !this.eventRouter.isCrosshairEnabled()) {
|
|
75768
|
+
// SphereEraser: direct click (Shift handled at mode level)
|
|
75769
|
+
this.activeWheelMode = 'sphereBrush';
|
|
75770
|
+
this.sphereBrushTool.onSphereEraserClick(e);
|
|
75771
|
+
}
|
|
75227
75772
|
else if (this.state.gui_states.mode.sphere && !this.eventRouter.isCrosshairEnabled()) {
|
|
75228
75773
|
this.handleSphereClick(e);
|
|
75229
75774
|
}
|
|
@@ -75245,6 +75790,16 @@ void main() {
|
|
|
75245
75790
|
this.drawingTool.onPointerUp(e);
|
|
75246
75791
|
this.activeWheelMode = 'zoom';
|
|
75247
75792
|
}
|
|
75793
|
+
else if (this.sphereBrushTool.isActive) {
|
|
75794
|
+
// SphereBrush or SphereEraser pointer-up
|
|
75795
|
+
if (this.state.gui_states.mode.sphereBrush) {
|
|
75796
|
+
this.sphereBrushTool.onSphereBrushPointerUp();
|
|
75797
|
+
}
|
|
75798
|
+
else if (this.state.gui_states.mode.sphereEraser) {
|
|
75799
|
+
this.sphereBrushTool.onSphereEraserPointerUp();
|
|
75800
|
+
}
|
|
75801
|
+
this.activeWheelMode = 'zoom';
|
|
75802
|
+
}
|
|
75248
75803
|
else if (this.state.gui_states.mode.sphere &&
|
|
75249
75804
|
!this.eventRouter.isCrosshairEnabled()) {
|
|
75250
75805
|
this.sphereTool.onSpherePointerUp();
|
|
@@ -75289,7 +75844,9 @@ void main() {
|
|
|
75289
75844
|
}
|
|
75290
75845
|
}
|
|
75291
75846
|
this.state.protectedData.ctxes.drawingCtx.drawImage(this.state.protectedData.canvases.drawingCanvasLayerMaster, 0, 0);
|
|
75292
|
-
if (this.state.gui_states.mode.sphere
|
|
75847
|
+
if (this.state.gui_states.mode.sphere
|
|
75848
|
+
|| this.state.gui_states.mode.sphereBrush
|
|
75849
|
+
|| this.state.gui_states.mode.sphereEraser) {
|
|
75293
75850
|
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);
|
|
75294
75851
|
}
|
|
75295
75852
|
}
|
|
@@ -75476,24 +76033,26 @@ void main() {
|
|
|
75476
76033
|
* Undo the last drawing operation on the active layer.
|
|
75477
76034
|
*/
|
|
75478
76035
|
undoLastPainting() {
|
|
75479
|
-
const
|
|
75480
|
-
if (!
|
|
75481
|
-
return;
|
|
75482
|
-
try {
|
|
75483
|
-
const vol = this.renderer.getVolumeForLayer(delta.layerId);
|
|
75484
|
-
vol.setSliceUint8(delta.sliceIndex, delta.oldSlice, delta.axis);
|
|
75485
|
-
}
|
|
75486
|
-
catch (_a) {
|
|
76036
|
+
const entry = this.undoManager.undo();
|
|
76037
|
+
if (!entry)
|
|
75487
76038
|
return;
|
|
75488
|
-
}
|
|
75489
76039
|
this.state.protectedData.isDrawing = true;
|
|
75490
|
-
|
|
75491
|
-
|
|
75492
|
-
|
|
75493
|
-
|
|
75494
|
-
|
|
75495
|
-
|
|
75496
|
-
|
|
76040
|
+
for (const delta of entry) {
|
|
76041
|
+
try {
|
|
76042
|
+
const vol = this.renderer.getVolumeForLayer(delta.layerId);
|
|
76043
|
+
vol.setSliceUint8(delta.sliceIndex, delta.oldSlice, delta.axis);
|
|
76044
|
+
}
|
|
76045
|
+
catch (_a) {
|
|
76046
|
+
continue;
|
|
76047
|
+
}
|
|
76048
|
+
if (delta.axis === this.state.protectedData.axis && delta.sliceIndex === this.state.nrrd_states.view.currentSliceIndex) {
|
|
76049
|
+
this.applyUndoRedoToCanvas(delta.layerId);
|
|
76050
|
+
}
|
|
76051
|
+
if (!this.state.nrrd_states.flags.loadingMaskData) {
|
|
76052
|
+
const { data: sliceData, width, height } = this.renderer.getVolumeForLayer(delta.layerId)
|
|
76053
|
+
.getSliceUint8(delta.sliceIndex, delta.axis);
|
|
76054
|
+
this.state.annotationCallbacks.onMaskChanged(sliceData, delta.layerId, this.state.gui_states.layerChannel.activeChannel || 1, delta.sliceIndex, delta.axis, width, height, false);
|
|
76055
|
+
}
|
|
75497
76056
|
}
|
|
75498
76057
|
this.setIsDrawFalse(1000);
|
|
75499
76058
|
}
|
|
@@ -75501,24 +76060,26 @@ void main() {
|
|
|
75501
76060
|
* Redo the last undone operation on the active layer.
|
|
75502
76061
|
*/
|
|
75503
76062
|
redoLastPainting() {
|
|
75504
|
-
const
|
|
75505
|
-
if (!
|
|
76063
|
+
const entry = this.undoManager.redo();
|
|
76064
|
+
if (!entry)
|
|
75506
76065
|
return;
|
|
75507
|
-
try {
|
|
75508
|
-
const vol = this.renderer.getVolumeForLayer(delta.layerId);
|
|
75509
|
-
vol.setSliceUint8(delta.sliceIndex, delta.newSlice, delta.axis);
|
|
75510
|
-
}
|
|
75511
|
-
catch (_a) {
|
|
75512
|
-
return;
|
|
75513
|
-
}
|
|
75514
76066
|
this.state.protectedData.isDrawing = true;
|
|
75515
|
-
|
|
75516
|
-
|
|
75517
|
-
|
|
75518
|
-
|
|
75519
|
-
|
|
75520
|
-
|
|
75521
|
-
|
|
76067
|
+
for (const delta of entry) {
|
|
76068
|
+
try {
|
|
76069
|
+
const vol = this.renderer.getVolumeForLayer(delta.layerId);
|
|
76070
|
+
vol.setSliceUint8(delta.sliceIndex, delta.newSlice, delta.axis);
|
|
76071
|
+
}
|
|
76072
|
+
catch (_a) {
|
|
76073
|
+
continue;
|
|
76074
|
+
}
|
|
76075
|
+
if (delta.axis === this.state.protectedData.axis && delta.sliceIndex === this.state.nrrd_states.view.currentSliceIndex) {
|
|
76076
|
+
this.applyUndoRedoToCanvas(delta.layerId);
|
|
76077
|
+
}
|
|
76078
|
+
if (!this.state.nrrd_states.flags.loadingMaskData) {
|
|
76079
|
+
const { data: sliceData, width, height } = this.renderer.getVolumeForLayer(delta.layerId)
|
|
76080
|
+
.getSliceUint8(delta.sliceIndex, delta.axis);
|
|
76081
|
+
this.state.annotationCallbacks.onMaskChanged(sliceData, delta.layerId, this.state.gui_states.layerChannel.activeChannel || 1, delta.sliceIndex, delta.axis, width, height, false);
|
|
76082
|
+
}
|
|
75522
76083
|
}
|
|
75523
76084
|
this.setIsDrawFalse(1000);
|
|
75524
76085
|
}
|
|
@@ -75592,6 +76153,14 @@ void main() {
|
|
|
75592
76153
|
exitSphereMode() {
|
|
75593
76154
|
throw new Error("exitSphereMode must be provided by NrrdTools");
|
|
75594
76155
|
}
|
|
76156
|
+
/** Override in NrrdTools */
|
|
76157
|
+
reloadMasksFromVolume() {
|
|
76158
|
+
throw new Error("reloadMasksFromVolume must be provided by NrrdTools");
|
|
76159
|
+
}
|
|
76160
|
+
/** Override in NrrdTools */
|
|
76161
|
+
updateMouseWheelEvent() {
|
|
76162
|
+
throw new Error("updateMouseWheelEvent must be provided by NrrdTools");
|
|
76163
|
+
}
|
|
75595
76164
|
}
|
|
75596
76165
|
|
|
75597
76166
|
/**
|
|
@@ -75655,6 +76224,7 @@ void main() {
|
|
|
75655
76224
|
nippleSphereOrigin: null,
|
|
75656
76225
|
sphereMaskVolume: null,
|
|
75657
76226
|
sphereRadius: 5,
|
|
76227
|
+
sphereBrushRadius: 10,
|
|
75658
76228
|
};
|
|
75659
76229
|
this.flags = {
|
|
75660
76230
|
stepClear: 1,
|
|
@@ -75676,6 +76246,7 @@ void main() {
|
|
|
75676
76246
|
this.sphere.nippleSphereOrigin = null;
|
|
75677
76247
|
this.sphere.sphereMaskVolume = null;
|
|
75678
76248
|
this.sphere.sphereRadius = 5;
|
|
76249
|
+
this.sphere.sphereBrushRadius = 10;
|
|
75679
76250
|
}
|
|
75680
76251
|
}
|
|
75681
76252
|
|
|
@@ -75697,6 +76268,8 @@ void main() {
|
|
|
75697
76268
|
pencil: true,
|
|
75698
76269
|
eraser: false,
|
|
75699
76270
|
sphere: false,
|
|
76271
|
+
sphereBrush: false,
|
|
76272
|
+
sphereEraser: false,
|
|
75700
76273
|
activeSphereType: "tumour",
|
|
75701
76274
|
};
|
|
75702
76275
|
this.drawing = {
|
|
@@ -75891,6 +76464,216 @@ void main() {
|
|
|
75891
76464
|
}
|
|
75892
76465
|
}
|
|
75893
76466
|
|
|
76467
|
+
/**
|
|
76468
|
+
* GaussianSmoother — 3D Gaussian smoothing for segmentation masks.
|
|
76469
|
+
*
|
|
76470
|
+
* Applies a separable 3D Gaussian blur to a single label channel within a
|
|
76471
|
+
* MaskVolume, then thresholds the result back to a binary mask. This smooths
|
|
76472
|
+
* jagged edges and fills small holes in segmentation annotations.
|
|
76473
|
+
*
|
|
76474
|
+
* Pure, stateless utility — no DOM/Canvas/GUI dependencies.
|
|
76475
|
+
*
|
|
76476
|
+
* Performance: uses direct typed-array access (bypassing getVoxel/setVoxel
|
|
76477
|
+
* boundary checks) and branch-free convolution for the interior region.
|
|
76478
|
+
*/
|
|
76479
|
+
class GaussianSmoother {
|
|
76480
|
+
/**
|
|
76481
|
+
* Smooth a single label channel in-place using separable 3D Gaussian blur.
|
|
76482
|
+
*
|
|
76483
|
+
* Steps:
|
|
76484
|
+
* 1. Extract — create a Float32Array with 1.0 where voxel === channel, 0.0 elsewhere.
|
|
76485
|
+
* 2. Blur — apply separable Gaussian (X → Y → Z) on the float buffer.
|
|
76486
|
+
* 3. Threshold — binarize at 0.5.
|
|
76487
|
+
* 4. Write back — overwrite/erase voxels according to the thresholded result.
|
|
76488
|
+
*
|
|
76489
|
+
* @param volume The MaskVolume to operate on (modified in-place).
|
|
76490
|
+
* @param channel The label value to smooth (must be > 0).
|
|
76491
|
+
* @param sigma Base Gaussian sigma in voxel units (default 1.0).
|
|
76492
|
+
* @param spacing Optional voxel spacing [sx, sy, sz]. When provided, per-axis
|
|
76493
|
+
* sigma is computed as `sigma / spacing[axis]` so that the
|
|
76494
|
+
* physical smoothing radius is isotropic.
|
|
76495
|
+
*/
|
|
76496
|
+
static gaussianSmooth3D(volume, channel, sigma = 1.0, spacing) {
|
|
76497
|
+
const dims = volume.getDimensions();
|
|
76498
|
+
const { width, height, depth } = dims;
|
|
76499
|
+
const totalVoxels = width * height * depth;
|
|
76500
|
+
// Direct access to underlying buffer — bypasses getVoxel/setVoxel
|
|
76501
|
+
// boundary checks for ~10x speedup in hot loops.
|
|
76502
|
+
const rawData = volume.getRawData();
|
|
76503
|
+
const channels = volume.getChannels();
|
|
76504
|
+
const bytesPerSlice = volume.getBytesPerSlice();
|
|
76505
|
+
const rowStride = width * channels;
|
|
76506
|
+
// Compute per-axis sigma (normalized to voxel units)
|
|
76507
|
+
const sigmaX = spacing ? sigma / spacing[0] : sigma;
|
|
76508
|
+
const sigmaY = spacing ? sigma / spacing[1] : sigma;
|
|
76509
|
+
const sigmaZ = spacing ? sigma / spacing[2] : sigma;
|
|
76510
|
+
// 1. Extract binary float buffer — direct array access
|
|
76511
|
+
const buffer = new Float32Array(totalVoxels);
|
|
76512
|
+
for (let z = 0; z < depth; z++) {
|
|
76513
|
+
const zOffset = z * bytesPerSlice;
|
|
76514
|
+
for (let y = 0; y < height; y++) {
|
|
76515
|
+
const zyOffset = zOffset + y * rowStride;
|
|
76516
|
+
for (let x = 0; x < width; x++) {
|
|
76517
|
+
const bufIdx = z * height * width + y * width + x;
|
|
76518
|
+
buffer[bufIdx] = rawData[zyOffset + x * channels] === channel ? 1.0 : 0.0;
|
|
76519
|
+
}
|
|
76520
|
+
}
|
|
76521
|
+
}
|
|
76522
|
+
// 2. Separable Gaussian blur: X → Y → Z
|
|
76523
|
+
const kernelX = GaussianSmoother.generateKernel1D(sigmaX);
|
|
76524
|
+
const kernelY = GaussianSmoother.generateKernel1D(sigmaY);
|
|
76525
|
+
const kernelZ = GaussianSmoother.generateKernel1D(sigmaZ);
|
|
76526
|
+
GaussianSmoother.convolve1D(buffer, width, height, depth, 0, kernelX); // X axis
|
|
76527
|
+
GaussianSmoother.convolve1D(buffer, width, height, depth, 1, kernelY); // Y axis
|
|
76528
|
+
GaussianSmoother.convolve1D(buffer, width, height, depth, 2, kernelZ); // Z axis
|
|
76529
|
+
// 3 & 4. Threshold and write back — direct array access
|
|
76530
|
+
for (let z = 0; z < depth; z++) {
|
|
76531
|
+
const zOffset = z * bytesPerSlice;
|
|
76532
|
+
for (let y = 0; y < height; y++) {
|
|
76533
|
+
const zyOffset = zOffset + y * rowStride;
|
|
76534
|
+
for (let x = 0; x < width; x++) {
|
|
76535
|
+
const bufIdx = z * height * width + y * width + x;
|
|
76536
|
+
const smoothed = buffer[bufIdx] >= 0.5 ? 1 : 0;
|
|
76537
|
+
const rawIdx = zyOffset + x * channels;
|
|
76538
|
+
const original = rawData[rawIdx];
|
|
76539
|
+
if (smoothed === 1 && original !== channel) {
|
|
76540
|
+
// Smoothed region expands into this voxel — overwrite
|
|
76541
|
+
rawData[rawIdx] = channel;
|
|
76542
|
+
}
|
|
76543
|
+
else if (smoothed === 0 && original === channel) {
|
|
76544
|
+
// Smoothed region retreats — erase
|
|
76545
|
+
rawData[rawIdx] = 0;
|
|
76546
|
+
}
|
|
76547
|
+
// Otherwise leave unchanged
|
|
76548
|
+
}
|
|
76549
|
+
}
|
|
76550
|
+
}
|
|
76551
|
+
}
|
|
76552
|
+
/**
|
|
76553
|
+
* Generate a normalized 1D Gaussian kernel truncated at ±3σ.
|
|
76554
|
+
*
|
|
76555
|
+
* @param sigma Standard deviation (in voxels). Must be > 0.
|
|
76556
|
+
* @returns Normalized Float32Array of odd length.
|
|
76557
|
+
*/
|
|
76558
|
+
static generateKernel1D(sigma) {
|
|
76559
|
+
if (sigma <= 0) {
|
|
76560
|
+
return new Float32Array([1.0]);
|
|
76561
|
+
}
|
|
76562
|
+
const radius = Math.ceil(sigma * 3);
|
|
76563
|
+
const size = 2 * radius + 1;
|
|
76564
|
+
const kernel = new Float32Array(size);
|
|
76565
|
+
const twoSigmaSq = 2 * sigma * sigma;
|
|
76566
|
+
let sum = 0;
|
|
76567
|
+
for (let i = 0; i < size; i++) {
|
|
76568
|
+
const x = i - radius;
|
|
76569
|
+
kernel[i] = Math.exp(-(x * x) / twoSigmaSq);
|
|
76570
|
+
sum += kernel[i];
|
|
76571
|
+
}
|
|
76572
|
+
// Normalize
|
|
76573
|
+
for (let i = 0; i < size; i++) {
|
|
76574
|
+
kernel[i] /= sum;
|
|
76575
|
+
}
|
|
76576
|
+
return kernel;
|
|
76577
|
+
}
|
|
76578
|
+
/**
|
|
76579
|
+
* In-place single-axis convolution with zero-padding at boundaries.
|
|
76580
|
+
*
|
|
76581
|
+
* Uses a 3-segment approach: left boundary (with bounds check),
|
|
76582
|
+
* middle interior (branch-free, ~95% of work), right boundary
|
|
76583
|
+
* (with bounds check). This eliminates per-element branching
|
|
76584
|
+
* in the hot inner loop.
|
|
76585
|
+
*
|
|
76586
|
+
* @param data Flat float buffer (width × height × depth).
|
|
76587
|
+
* @param width X dimension.
|
|
76588
|
+
* @param height Y dimension.
|
|
76589
|
+
* @param depth Z dimension.
|
|
76590
|
+
* @param axis 0 = X, 1 = Y, 2 = Z.
|
|
76591
|
+
* @param kernel 1D convolution kernel (odd length).
|
|
76592
|
+
*/
|
|
76593
|
+
static convolve1D(data, width, height, depth, axis, kernel) {
|
|
76594
|
+
const kLen = kernel.length;
|
|
76595
|
+
const radius = (kLen - 1) / 2;
|
|
76596
|
+
// Determine the dimension length along the convolution axis
|
|
76597
|
+
// and the stride between consecutive elements along that axis
|
|
76598
|
+
let axisLen;
|
|
76599
|
+
let stride;
|
|
76600
|
+
let outerCount;
|
|
76601
|
+
if (axis === 0) {
|
|
76602
|
+
// X axis: stride = 1, iterate over all (y, z) lines
|
|
76603
|
+
axisLen = width;
|
|
76604
|
+
stride = 1;
|
|
76605
|
+
outerCount = height * depth;
|
|
76606
|
+
}
|
|
76607
|
+
else if (axis === 1) {
|
|
76608
|
+
// Y axis: stride = width, iterate over all (x, z) lines
|
|
76609
|
+
axisLen = height;
|
|
76610
|
+
stride = width;
|
|
76611
|
+
outerCount = width * depth;
|
|
76612
|
+
}
|
|
76613
|
+
else {
|
|
76614
|
+
// Z axis: stride = width * height, iterate over all (x, y) lines
|
|
76615
|
+
axisLen = depth;
|
|
76616
|
+
stride = width * height;
|
|
76617
|
+
outerCount = width * height;
|
|
76618
|
+
}
|
|
76619
|
+
// Precompute middle segment bounds
|
|
76620
|
+
const midStart = radius;
|
|
76621
|
+
const midEnd = axisLen - radius;
|
|
76622
|
+
// Temporary line buffer to avoid read-after-write issues
|
|
76623
|
+
const line = new Float32Array(axisLen);
|
|
76624
|
+
for (let outer = 0; outer < outerCount; outer++) {
|
|
76625
|
+
// Compute the starting index for this line
|
|
76626
|
+
let lineStart;
|
|
76627
|
+
if (axis === 0) {
|
|
76628
|
+
lineStart = outer * width;
|
|
76629
|
+
}
|
|
76630
|
+
else if (axis === 1) {
|
|
76631
|
+
const z = Math.floor(outer / width);
|
|
76632
|
+
const x = outer % width;
|
|
76633
|
+
lineStart = z * width * height + x;
|
|
76634
|
+
}
|
|
76635
|
+
else {
|
|
76636
|
+
lineStart = outer;
|
|
76637
|
+
}
|
|
76638
|
+
// Read the line
|
|
76639
|
+
for (let i = 0; i < axisLen; i++) {
|
|
76640
|
+
line[i] = data[lineStart + i * stride];
|
|
76641
|
+
}
|
|
76642
|
+
// Left boundary segment (i = 0 .. radius-1): lower bound check
|
|
76643
|
+
for (let i = 0; i < midStart; i++) {
|
|
76644
|
+
let sum = 0;
|
|
76645
|
+
for (let k = 0; k < kLen; k++) {
|
|
76646
|
+
const j = i + k - radius;
|
|
76647
|
+
if (j >= 0) {
|
|
76648
|
+
sum += line[j] * kernel[k];
|
|
76649
|
+
}
|
|
76650
|
+
}
|
|
76651
|
+
data[lineStart + i * stride] = sum;
|
|
76652
|
+
}
|
|
76653
|
+
// Middle segment (i = radius .. axisLen-radius-1): NO bounds check
|
|
76654
|
+
for (let i = midStart; i < midEnd; i++) {
|
|
76655
|
+
let sum = 0;
|
|
76656
|
+
const lineOffset = i - radius;
|
|
76657
|
+
for (let k = 0; k < kLen; k++) {
|
|
76658
|
+
sum += line[lineOffset + k] * kernel[k];
|
|
76659
|
+
}
|
|
76660
|
+
data[lineStart + i * stride] = sum;
|
|
76661
|
+
}
|
|
76662
|
+
// Right boundary segment (i = axisLen-radius .. axisLen-1): upper bound check
|
|
76663
|
+
for (let i = midEnd; i < axisLen; i++) {
|
|
76664
|
+
let sum = 0;
|
|
76665
|
+
for (let k = 0; k < kLen; k++) {
|
|
76666
|
+
const j = i + k - radius;
|
|
76667
|
+
if (j < axisLen) {
|
|
76668
|
+
sum += line[j] * kernel[k];
|
|
76669
|
+
}
|
|
76670
|
+
}
|
|
76671
|
+
data[lineStart + i * stride] = sum;
|
|
76672
|
+
}
|
|
76673
|
+
}
|
|
76674
|
+
}
|
|
76675
|
+
}
|
|
76676
|
+
|
|
75894
76677
|
/**
|
|
75895
76678
|
* Manages layer/channel state: active selection, visibility, and channel colors.
|
|
75896
76679
|
*
|
|
@@ -76407,6 +77190,8 @@ void main() {
|
|
|
76407
77190
|
* Then update the changedWidth and changedHeight based on the sizeFactor.
|
|
76408
77191
|
*/
|
|
76409
77192
|
updateOriginAndChangedWH() {
|
|
77193
|
+
const prevW = this.ctx.nrrd_states.image.originWidth;
|
|
77194
|
+
const prevH = this.ctx.nrrd_states.image.originHeight;
|
|
76410
77195
|
this.ctx.nrrd_states.image.originWidth =
|
|
76411
77196
|
this.ctx.protectedData.canvases.originCanvas.width;
|
|
76412
77197
|
this.ctx.nrrd_states.image.originHeight =
|
|
@@ -76415,7 +77200,12 @@ void main() {
|
|
|
76415
77200
|
// Setting them here would defeat the sizeChanged detection in resizePaintArea,
|
|
76416
77201
|
// causing canvas elements to keep stale dimensions after axis switches.
|
|
76417
77202
|
this.resizePaintArea(this.ctx.nrrd_states.view.sizeFactor);
|
|
76418
|
-
|
|
77203
|
+
// Only re-center when origin dimensions changed (e.g. axis switch).
|
|
77204
|
+
// Contrast toggle doesn't change origin size, so skip to preserve user's pan/zoom.
|
|
77205
|
+
if (prevW !== this.ctx.nrrd_states.image.originWidth ||
|
|
77206
|
+
prevH !== this.ctx.nrrd_states.image.originHeight) {
|
|
77207
|
+
this.resetPaintAreaUIPosition();
|
|
77208
|
+
}
|
|
76419
77209
|
}
|
|
76420
77210
|
/**
|
|
76421
77211
|
* Keep all contrast slice index to same.
|
|
@@ -76629,7 +77419,7 @@ void main() {
|
|
|
76629
77419
|
// Wire RenderingUtils' setEmptyCanvasSize callback
|
|
76630
77420
|
this.drawCore.renderer.setEmptyCanvasSize = (axis) => this.setEmptyCanvasSize(axis);
|
|
76631
77421
|
this.init();
|
|
76632
|
-
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));
|
|
77422
|
+
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));
|
|
76633
77423
|
// Inject EventRouter into DragOperator for centralized event handling
|
|
76634
77424
|
if (this.drawCore.eventRouter) {
|
|
76635
77425
|
this.dragOperator.setEventRouter(this.drawCore.eventRouter);
|
|
@@ -76656,6 +77446,8 @@ void main() {
|
|
|
76656
77446
|
this.drawCore.enterSphereMode = () => this.enterSphereMode();
|
|
76657
77447
|
this.drawCore.exitSphereMode = () => this.exitSphereMode();
|
|
76658
77448
|
this.drawCore.configMouseSliceWheel = () => this.configMouseSliceWheel();
|
|
77449
|
+
this.drawCore.reloadMasksFromVolume = () => this.reloadMasksFromVolume();
|
|
77450
|
+
this.drawCore.updateMouseWheelEvent = () => this.updateMouseWheelEvent();
|
|
76659
77451
|
}
|
|
76660
77452
|
/**
|
|
76661
77453
|
* A initialise function for nrrd_tools
|
|
@@ -76776,13 +77568,18 @@ void main() {
|
|
|
76776
77568
|
* Set the current tool mode.
|
|
76777
77569
|
*/
|
|
76778
77570
|
setMode(mode) {
|
|
77571
|
+
var _a, _b, _c;
|
|
76779
77572
|
if (!this.guiCallbacks)
|
|
76780
77573
|
return;
|
|
76781
77574
|
const prevSphere = this.state.gui_states.mode.sphere;
|
|
77575
|
+
const prevSphereBrush = this.state.gui_states.mode.sphereBrush;
|
|
77576
|
+
const prevSphereEraser = this.state.gui_states.mode.sphereEraser;
|
|
76782
77577
|
const prevCalculator = this._calculatorActive;
|
|
76783
77578
|
this.state.gui_states.mode.pencil = false;
|
|
76784
77579
|
this.state.gui_states.mode.eraser = false;
|
|
76785
77580
|
this.state.gui_states.mode.sphere = false;
|
|
77581
|
+
this.state.gui_states.mode.sphereBrush = false;
|
|
77582
|
+
this.state.gui_states.mode.sphereEraser = false;
|
|
76786
77583
|
this._calculatorActive = false;
|
|
76787
77584
|
switch (mode) {
|
|
76788
77585
|
case "pencil":
|
|
@@ -76803,6 +77600,24 @@ void main() {
|
|
|
76803
77600
|
this.state.gui_states.mode.sphere = true;
|
|
76804
77601
|
this._calculatorActive = true;
|
|
76805
77602
|
break;
|
|
77603
|
+
case "sphereBrush":
|
|
77604
|
+
this.state.gui_states.mode.sphereBrush = true;
|
|
77605
|
+
this.dragOperator.removeDragMode();
|
|
77606
|
+
(_a = this.drawCore.eventRouter) === null || _a === void 0 ? void 0 : _a.setGuiTool('sphereBrush');
|
|
77607
|
+
break;
|
|
77608
|
+
case "sphereEraser":
|
|
77609
|
+
this.state.gui_states.mode.sphereEraser = true;
|
|
77610
|
+
this.dragOperator.removeDragMode();
|
|
77611
|
+
(_b = this.drawCore.eventRouter) === null || _b === void 0 ? void 0 : _b.setGuiTool('sphereEraser');
|
|
77612
|
+
break;
|
|
77613
|
+
}
|
|
77614
|
+
// Restore drag mode when leaving sphereBrush/sphereEraser
|
|
77615
|
+
if ((prevSphereBrush || prevSphereEraser)
|
|
77616
|
+
&& !this.state.gui_states.mode.sphereBrush
|
|
77617
|
+
&& !this.state.gui_states.mode.sphereEraser
|
|
77618
|
+
&& !this.state.gui_states.mode.sphere) {
|
|
77619
|
+
this.dragOperator.configDragMode();
|
|
77620
|
+
(_c = this.drawCore.eventRouter) === null || _c === void 0 ? void 0 : _c.setGuiTool('pencil');
|
|
76806
77621
|
}
|
|
76807
77622
|
if (prevSphere && !this.state.gui_states.mode.sphere) {
|
|
76808
77623
|
this.guiCallbacks.updateSphereState();
|
|
@@ -76815,6 +77630,10 @@ void main() {
|
|
|
76815
77630
|
getMode() {
|
|
76816
77631
|
if (this._calculatorActive)
|
|
76817
77632
|
return "calculator";
|
|
77633
|
+
if (this.state.gui_states.mode.sphereBrush)
|
|
77634
|
+
return "sphereBrush";
|
|
77635
|
+
if (this.state.gui_states.mode.sphereEraser)
|
|
77636
|
+
return "sphereEraser";
|
|
76818
77637
|
if (this.state.gui_states.mode.sphere)
|
|
76819
77638
|
return "sphere";
|
|
76820
77639
|
if (this.state.gui_states.mode.eraser)
|
|
@@ -76840,6 +77659,12 @@ void main() {
|
|
|
76840
77659
|
getBrushSize() {
|
|
76841
77660
|
return this.state.gui_states.drawing.brushAndEraserSize;
|
|
76842
77661
|
}
|
|
77662
|
+
setSphereBrushRadius(radius) {
|
|
77663
|
+
this.state.nrrd_states.sphere.sphereBrushRadius = Math.max(1, Math.min(50, radius));
|
|
77664
|
+
}
|
|
77665
|
+
getSphereBrushRadius() {
|
|
77666
|
+
return this.state.nrrd_states.sphere.sphereBrushRadius;
|
|
77667
|
+
}
|
|
76843
77668
|
setWindowHigh(value) {
|
|
76844
77669
|
var _a;
|
|
76845
77670
|
this.state.gui_states.viewConfig.readyToUpdate = false;
|
|
@@ -76913,7 +77738,8 @@ void main() {
|
|
|
76913
77738
|
getPencilColor() {
|
|
76914
77739
|
return this.state.gui_states.drawing.color;
|
|
76915
77740
|
}
|
|
76916
|
-
executeAction(action) {
|
|
77741
|
+
executeAction(action, opts) {
|
|
77742
|
+
var _a;
|
|
76917
77743
|
switch (action) {
|
|
76918
77744
|
case "undo":
|
|
76919
77745
|
this.undo();
|
|
@@ -76951,6 +77777,48 @@ void main() {
|
|
|
76951
77777
|
enableDownload(config);
|
|
76952
77778
|
break;
|
|
76953
77779
|
}
|
|
77780
|
+
case "gaussianSmooth": {
|
|
77781
|
+
const layerId = this.state.gui_states.layerChannel.layer;
|
|
77782
|
+
const channel = this.state.gui_states.layerChannel.activeChannel;
|
|
77783
|
+
const volume = this.drawCore.renderer.getVolumeForLayer(layerId);
|
|
77784
|
+
if (volume && channel > 0) {
|
|
77785
|
+
const dims = volume.getDimensions();
|
|
77786
|
+
// 1. Snapshot all Z-slices before mutation (undo support)
|
|
77787
|
+
const deltas = [];
|
|
77788
|
+
for (let z = 0; z < dims.depth; z++) {
|
|
77789
|
+
const sliceData = volume.getSliceUint8(z, "z").data;
|
|
77790
|
+
const hasChannel = sliceData.some((v) => v === channel);
|
|
77791
|
+
if (hasChannel) {
|
|
77792
|
+
deltas.push({
|
|
77793
|
+
layerId,
|
|
77794
|
+
axis: "z",
|
|
77795
|
+
sliceIndex: z,
|
|
77796
|
+
oldSlice: sliceData.slice(),
|
|
77797
|
+
newSlice: new Uint8Array(0), // placeholder, filled after smoothing
|
|
77798
|
+
});
|
|
77799
|
+
}
|
|
77800
|
+
}
|
|
77801
|
+
// 2. Apply smoothing with anisotropic spacing
|
|
77802
|
+
const spacing = this.state.nrrd_states.image.voxelSpacing;
|
|
77803
|
+
const spacingTuple = spacing.length >= 3 ? [spacing[0], spacing[1], spacing[2]] : undefined;
|
|
77804
|
+
const sigma = (_a = opts === null || opts === void 0 ? void 0 : opts.sigma) !== null && _a !== void 0 ? _a : 1.0;
|
|
77805
|
+
GaussianSmoother.gaussianSmooth3D(volume, channel, sigma, spacingTuple);
|
|
77806
|
+
// 3. Capture newSlice data after smoothing and push undo group
|
|
77807
|
+
for (const delta of deltas) {
|
|
77808
|
+
delta.newSlice = volume.getSliceUint8(delta.sliceIndex, "z").data.slice();
|
|
77809
|
+
}
|
|
77810
|
+
if (deltas.length > 0) {
|
|
77811
|
+
this.drawCore.undoManager.pushGroup(deltas);
|
|
77812
|
+
}
|
|
77813
|
+
// 4. Notify backend for each changed slice
|
|
77814
|
+
for (const delta of deltas) {
|
|
77815
|
+
const { data: sliceData, width, height } = volume.getSliceUint8(delta.sliceIndex, "z");
|
|
77816
|
+
this.state.annotationCallbacks.onMaskChanged(sliceData, layerId, channel, delta.sliceIndex, "z", width, height, false);
|
|
77817
|
+
}
|
|
77818
|
+
this.reloadMasksFromVolume();
|
|
77819
|
+
}
|
|
77820
|
+
break;
|
|
77821
|
+
}
|
|
76954
77822
|
}
|
|
76955
77823
|
}
|
|
76956
77824
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -77389,10 +78257,15 @@ void main() {
|
|
|
77389
78257
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
77390
78258
|
configMouseSliceWheel() {
|
|
77391
78259
|
const handleMouseZoomSliceWheelMove = (e) => {
|
|
77392
|
-
var _a;
|
|
78260
|
+
var _a, _b;
|
|
77393
78261
|
if ((_a = this.drawCore.eventRouter) === null || _a === void 0 ? void 0 : _a.isShiftHeld()) {
|
|
77394
78262
|
return;
|
|
77395
78263
|
}
|
|
78264
|
+
// Block slice wheel when sphereBrush/sphereEraser is actively placing (left button held)
|
|
78265
|
+
if ((this.state.gui_states.mode.sphereBrush || this.state.gui_states.mode.sphereEraser)
|
|
78266
|
+
&& ((_b = this.drawCore.eventRouter) === null || _b === void 0 ? void 0 : _b.isLeftButtonDown())) {
|
|
78267
|
+
return;
|
|
78268
|
+
}
|
|
77396
78269
|
e.preventDefault();
|
|
77397
78270
|
if (e.deltaY < 0) {
|
|
77398
78271
|
this.setSliceMoving(-1);
|
|
@@ -77660,13 +78533,14 @@ void main() {
|
|
|
77660
78533
|
}
|
|
77661
78534
|
|
|
77662
78535
|
// import * as kiwrious from "copper3d_plugin_heart_k";
|
|
77663
|
-
const REVISION = "v3.1
|
|
78536
|
+
const REVISION = "v3.2.1-beta";
|
|
77664
78537
|
console.log(`%cCopper3D Visualisation %cBeta:${REVISION}`, "padding: 3px;color:white; background:#023047", "padding: 3px;color:white; background:#f50a25");
|
|
77665
78538
|
|
|
77666
78539
|
exports.CHANNEL_COLORS = CHANNEL_COLORS;
|
|
77667
78540
|
exports.CHANNEL_HEX_COLORS = CHANNEL_HEX_COLORS;
|
|
77668
78541
|
exports.CameraViewPoint = CameraViewPoint;
|
|
77669
78542
|
exports.Copper3dTrackballControls = Copper3dTrackballControls;
|
|
78543
|
+
exports.GaussianSmoother = GaussianSmoother;
|
|
77670
78544
|
exports.MeshNodeTool = MeshNodeTool;
|
|
77671
78545
|
exports.NrrdTools = NrrdTools;
|
|
77672
78546
|
exports.REVISION = REVISION;
|