copper3d 3.4.9 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Renderer/baseRenderer.d.ts +3 -0
- package/dist/Renderer/baseRenderer.js +20 -0
- package/dist/Renderer/baseRenderer.js.map +1 -1
- package/dist/Renderer/copperMSceneRenderer.d.ts +3 -0
- package/dist/Renderer/copperMSceneRenderer.js +22 -0
- package/dist/Renderer/copperMSceneRenderer.js.map +1 -1
- package/dist/Renderer/copperRenderer.d.ts +0 -2
- package/dist/Renderer/copperRenderer.js +0 -5
- package/dist/Renderer/copperRenderer.js.map +1 -1
- package/dist/Utils/segmentation/DrawToolCore.d.ts +3 -0
- package/dist/Utils/segmentation/DrawToolCore.js +29 -1
- package/dist/Utils/segmentation/DrawToolCore.js.map +1 -1
- package/dist/Utils/segmentation/NrrdTools.d.ts +54 -0
- package/dist/Utils/segmentation/NrrdTools.js +119 -0
- package/dist/Utils/segmentation/NrrdTools.js.map +1 -1
- package/dist/Utils/segmentation/core/index.d.ts +1 -1
- package/dist/Utils/segmentation/core/index.js +1 -1
- package/dist/Utils/segmentation/core/index.js.map +1 -1
- package/dist/Utils/segmentation/core/types.d.ts +11 -1
- package/dist/Utils/segmentation/core/types.js +30 -0
- package/dist/Utils/segmentation/core/types.js.map +1 -1
- package/dist/Utils/segmentation/eventRouter/EventRouter.d.ts +2 -0
- package/dist/Utils/segmentation/eventRouter/EventRouter.js +37 -6
- package/dist/Utils/segmentation/eventRouter/EventRouter.js.map +1 -1
- package/dist/Utils/segmentation/eventRouter/types.d.ts +3 -2
- package/dist/Utils/segmentation/tools/AiAssistTool.d.ts +146 -0
- package/dist/Utils/segmentation/tools/AiAssistTool.js +470 -0
- package/dist/Utils/segmentation/tools/AiAssistTool.js.map +1 -0
- package/dist/bundle.esm.js +726 -14
- package/dist/bundle.umd.js +727 -13
- package/dist/index.d.ts +5 -4
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/types/Renderer/baseRenderer.d.ts +3 -0
- package/dist/types/Renderer/copperMSceneRenderer.d.ts +3 -0
- package/dist/types/Renderer/copperRenderer.d.ts +0 -2
- package/dist/types/Utils/segmentation/DrawToolCore.d.ts +3 -0
- package/dist/types/Utils/segmentation/NrrdTools.d.ts +54 -0
- package/dist/types/Utils/segmentation/core/index.d.ts +1 -1
- package/dist/types/Utils/segmentation/core/types.d.ts +11 -1
- package/dist/types/Utils/segmentation/eventRouter/EventRouter.d.ts +2 -0
- package/dist/types/Utils/segmentation/eventRouter/types.d.ts +3 -2
- package/dist/types/Utils/segmentation/tools/AiAssistTool.d.ts +146 -0
- package/dist/types/index.d.ts +5 -4
- package/package.json +1 -1
package/dist/bundle.umd.js
CHANGED
|
@@ -50094,6 +50094,7 @@ void main() {
|
|
|
50094
50094
|
class baseRenderer {
|
|
50095
50095
|
constructor(container, options) {
|
|
50096
50096
|
var _a, _b, _c, _d, _e;
|
|
50097
|
+
this.running = true;
|
|
50097
50098
|
this.visualCtrls = [];
|
|
50098
50099
|
this.container = container;
|
|
50099
50100
|
this.options = options;
|
|
@@ -50194,6 +50195,25 @@ void main() {
|
|
|
50194
50195
|
setClearColor(clearColor = 0x000000, alpha = 0) {
|
|
50195
50196
|
this.renderer.setClearColor(clearColor, alpha);
|
|
50196
50197
|
}
|
|
50198
|
+
// Stop the render loop (cancels the requestAnimationFrame chain).
|
|
50199
|
+
stop() {
|
|
50200
|
+
this.running = false;
|
|
50201
|
+
}
|
|
50202
|
+
// Full teardown: stop the loop, free the GPU resources, release the WebGL context
|
|
50203
|
+
// (browsers cap live contexts ~16), and remove the canvas. Call on page unmount.
|
|
50204
|
+
dispose() {
|
|
50205
|
+
var _a, _b;
|
|
50206
|
+
this.running = false;
|
|
50207
|
+
try {
|
|
50208
|
+
(_a = this.pmremGenerator) === null || _a === void 0 ? void 0 : _a.dispose();
|
|
50209
|
+
this.renderer.dispose();
|
|
50210
|
+
this.renderer.forceContextLoss();
|
|
50211
|
+
}
|
|
50212
|
+
catch (e) {
|
|
50213
|
+
/* ignore */
|
|
50214
|
+
}
|
|
50215
|
+
(_b = this.renderer.domElement) === null || _b === void 0 ? void 0 : _b.remove();
|
|
50216
|
+
}
|
|
50197
50217
|
addGui() {
|
|
50198
50218
|
var _a, _b, _c;
|
|
50199
50219
|
const gui = (this.gui = new GUI$1({
|
|
@@ -59359,7 +59379,6 @@ void main() {
|
|
|
59359
59379
|
this.delta = 0;
|
|
59360
59380
|
this.interval = 1 / this.fps;
|
|
59361
59381
|
this.preRenderCallbackFunctions = [];
|
|
59362
|
-
this.running = true;
|
|
59363
59382
|
this.animate = (time) => {
|
|
59364
59383
|
var _a, _b, _c;
|
|
59365
59384
|
if (!this.running)
|
|
@@ -59390,10 +59409,6 @@ void main() {
|
|
|
59390
59409
|
}
|
|
59391
59410
|
};
|
|
59392
59411
|
}
|
|
59393
|
-
// Stop the render loop (call on teardown to release the RAF and let GC reclaim).
|
|
59394
|
-
stop() {
|
|
59395
|
-
this.running = false;
|
|
59396
|
-
}
|
|
59397
59412
|
getSceneByName(name) {
|
|
59398
59413
|
return this.sceneMap[name];
|
|
59399
59414
|
}
|
|
@@ -59918,6 +59933,7 @@ void main() {
|
|
|
59918
59933
|
alpha: true,
|
|
59919
59934
|
antialias: true,
|
|
59920
59935
|
});
|
|
59936
|
+
this.running = true;
|
|
59921
59937
|
this.renderSceneInfo = (sceneInfo) => {
|
|
59922
59938
|
const elem = sceneInfo.container;
|
|
59923
59939
|
// get the viewpoint relative position of this element
|
|
@@ -59956,6 +59972,8 @@ void main() {
|
|
|
59956
59972
|
}
|
|
59957
59973
|
};
|
|
59958
59974
|
this.animate = () => {
|
|
59975
|
+
if (!this.running)
|
|
59976
|
+
return;
|
|
59959
59977
|
const clearColor = new Color$1("#000");
|
|
59960
59978
|
this.renderer.setScissorTest(false);
|
|
59961
59979
|
this.renderer.setClearColor(clearColor, 0);
|
|
@@ -60021,6 +60039,25 @@ void main() {
|
|
|
60021
60039
|
}, undefined, reject);
|
|
60022
60040
|
});
|
|
60023
60041
|
}
|
|
60042
|
+
stop() {
|
|
60043
|
+
this.running = false;
|
|
60044
|
+
}
|
|
60045
|
+
// Full teardown: stop the loop, free GPU resources, release the WebGL context, and
|
|
60046
|
+
// remove the canvas. Call on page unmount to avoid context exhaustion / leaks.
|
|
60047
|
+
dispose() {
|
|
60048
|
+
var _a, _b;
|
|
60049
|
+
this.running = false;
|
|
60050
|
+
try {
|
|
60051
|
+
(_a = this.pmremGenerator) === null || _a === void 0 ? void 0 : _a.dispose();
|
|
60052
|
+
this.renderer.dispose();
|
|
60053
|
+
this.renderer.forceContextLoss();
|
|
60054
|
+
}
|
|
60055
|
+
catch (e) {
|
|
60056
|
+
/* ignore */
|
|
60057
|
+
}
|
|
60058
|
+
(_b = this.canvas) === null || _b === void 0 ? void 0 : _b.remove();
|
|
60059
|
+
this.elems.forEach((el) => el.remove());
|
|
60060
|
+
}
|
|
60024
60061
|
}
|
|
60025
60062
|
|
|
60026
60063
|
/******************************************************************************
|
|
@@ -70440,6 +70477,36 @@ void main() {
|
|
|
70440
70477
|
7: '#f97316',
|
|
70441
70478
|
8: '#8b5cf6', // Violet
|
|
70442
70479
|
};
|
|
70480
|
+
/**
|
|
70481
|
+
* AI-Assist channel palette. Identical to the default palette EXCEPT:
|
|
70482
|
+
* - channel 1 = cyan (the AI-Assist accent #5ec8ff), so the 2D AI overlay aligns
|
|
70483
|
+
* with the cyan ai_generated GLB in the 3D panel;
|
|
70484
|
+
* - channel 6 takes the emerald that channel 1 vacated, so cyan isn't duplicated.
|
|
70485
|
+
* Applied ONLY to the AI scratch layer (set per-volume in AiAssistTool.enter) —
|
|
70486
|
+
* the global palette above is untouched, so the clinician mask stays emerald.
|
|
70487
|
+
*/
|
|
70488
|
+
const AI_MASK_CHANNEL_COLORS = {
|
|
70489
|
+
0: { r: 0, g: 0, b: 0, a: 0 },
|
|
70490
|
+
1: { r: 94, g: 200, b: 255, a: 255 },
|
|
70491
|
+
2: { r: 244, g: 63, b: 94, a: 255 },
|
|
70492
|
+
3: { r: 59, g: 130, b: 246, a: 255 },
|
|
70493
|
+
4: { r: 251, g: 191, b: 36, a: 255 },
|
|
70494
|
+
5: { r: 217, g: 70, b: 239, a: 255 },
|
|
70495
|
+
6: { r: 16, g: 185, b: 129, a: 255 },
|
|
70496
|
+
7: { r: 249, g: 115, b: 22, a: 255 },
|
|
70497
|
+
8: { r: 139, g: 92, b: 246, a: 255 }, // Violet
|
|
70498
|
+
};
|
|
70499
|
+
const AI_CHANNEL_HEX_COLORS = {
|
|
70500
|
+
0: '#000000',
|
|
70501
|
+
1: '#5ec8ff',
|
|
70502
|
+
2: '#f43f5e',
|
|
70503
|
+
3: '#3b82f6',
|
|
70504
|
+
4: '#fbbf24',
|
|
70505
|
+
5: '#d946ef',
|
|
70506
|
+
6: '#10b981',
|
|
70507
|
+
7: '#f97316',
|
|
70508
|
+
8: '#8b5cf6', // Violet
|
|
70509
|
+
};
|
|
70443
70510
|
// ── Color Conversion Utilities ──────────────────────────────────────────
|
|
70444
70511
|
/**
|
|
70445
70512
|
* Convert an RGBAColor to a hex string (no alpha), e.g. '#ff0000'.
|
|
@@ -73588,6 +73655,8 @@ void main() {
|
|
|
73588
73655
|
constructor(config) {
|
|
73589
73656
|
// === State ===
|
|
73590
73657
|
this.mode = 'idle';
|
|
73658
|
+
/** Mode to restore after a right-drag pan ends (so pan doesn't drop aiAssist). */
|
|
73659
|
+
this.modeBeforePan = 'idle';
|
|
73591
73660
|
this.guiTool = 'pencil';
|
|
73592
73661
|
this.state = {
|
|
73593
73662
|
shiftHeld: false,
|
|
@@ -73713,7 +73782,20 @@ void main() {
|
|
|
73713
73782
|
* Set the GUI tool (from UI buttons).
|
|
73714
73783
|
*/
|
|
73715
73784
|
setGuiTool(tool) {
|
|
73785
|
+
const prevTool = this.guiTool;
|
|
73716
73786
|
this.guiTool = tool;
|
|
73787
|
+
// AI-assist owns the canvas: left-click/drag = prompt. Taking the
|
|
73788
|
+
// 'aiAssist' mode (not 'idle') is what auto-disables left-drag slice
|
|
73789
|
+
// scrubbing — `isDragSliceActive()` requires mode === 'idle'.
|
|
73790
|
+
if (tool === 'aiAssist') {
|
|
73791
|
+
this.state.crosshairEnabled = false;
|
|
73792
|
+
this.setMode('aiAssist');
|
|
73793
|
+
return;
|
|
73794
|
+
}
|
|
73795
|
+
// Leaving aiAssist → restore idle so slice-drag / wheel resume.
|
|
73796
|
+
if (prevTool === 'aiAssist' && this.mode === 'aiAssist') {
|
|
73797
|
+
this.setMode('idle');
|
|
73798
|
+
}
|
|
73717
73799
|
// When entering any sphere-family tool, keep crosshair if active, otherwise idle
|
|
73718
73800
|
if (SPHERE_TOOLS.has(tool)) {
|
|
73719
73801
|
if (!this.state.crosshairEnabled) {
|
|
@@ -73727,8 +73809,9 @@ void main() {
|
|
|
73727
73809
|
* Blocked when draw or contrast mode is active, or left button is held (mutual exclusion).
|
|
73728
73810
|
*/
|
|
73729
73811
|
toggleCrosshair() {
|
|
73730
|
-
// Allow crosshair in drawing tools
|
|
73731
|
-
|
|
73812
|
+
// Allow crosshair in drawing tools, all sphere-family tools, AND aiAssist
|
|
73813
|
+
// (so the crosshair can be used to debug while AI-assist is active).
|
|
73814
|
+
if (!DRAWING_TOOLS.has(this.guiTool) && !SPHERE_TOOLS.has(this.guiTool) && this.guiTool !== 'aiAssist')
|
|
73732
73815
|
return;
|
|
73733
73816
|
// Block crosshair activation during draw, contrast, or while left button held.
|
|
73734
73817
|
// The leftButtonDown guard also enforces "once a sphere preview is on screen,
|
|
@@ -73736,7 +73819,15 @@ void main() {
|
|
|
73736
73819
|
if (this.state.shiftHeld || this.state.leftButtonDown || this.mode === 'draw' || this.mode === 'contrast')
|
|
73737
73820
|
return;
|
|
73738
73821
|
this.state.crosshairEnabled = !this.state.crosshairEnabled;
|
|
73739
|
-
|
|
73822
|
+
// Crosshair has PRIORITY over aiAssist: turning it on takes the mode (so
|
|
73823
|
+
// AI point/box/scribble are suspended); turning it off restores aiAssist
|
|
73824
|
+
// (not idle) when the AI tool is the active tool, otherwise idle.
|
|
73825
|
+
if (this.state.crosshairEnabled) {
|
|
73826
|
+
this.setMode('crosshair');
|
|
73827
|
+
}
|
|
73828
|
+
else {
|
|
73829
|
+
this.setMode(this.guiTool === 'aiAssist' ? 'aiAssist' : 'idle');
|
|
73830
|
+
}
|
|
73740
73831
|
}
|
|
73741
73832
|
/**
|
|
73742
73833
|
* Check if crosshair mode is enabled.
|
|
@@ -73872,8 +73963,8 @@ void main() {
|
|
|
73872
73963
|
}
|
|
73873
73964
|
}
|
|
73874
73965
|
if (this.contrastEnabled && this.keyboardSettings.contrast.includes(ev.key)) {
|
|
73875
|
-
// Block contrast state when crosshair, draw, or any sphere-family tool is active (mutual exclusion)
|
|
73876
|
-
if (!this.state.crosshairEnabled && this.mode !== 'draw'
|
|
73966
|
+
// Block contrast state when crosshair, draw, aiAssist, or any sphere-family tool is active (mutual exclusion)
|
|
73967
|
+
if (!this.state.crosshairEnabled && this.mode !== 'draw' && this.mode !== 'aiAssist'
|
|
73877
73968
|
&& !SPHERE_TOOLS.has(this.guiTool)) {
|
|
73878
73969
|
this.state.ctrlHeld = true;
|
|
73879
73970
|
}
|
|
@@ -73910,6 +74001,10 @@ void main() {
|
|
|
73910
74001
|
}
|
|
73911
74002
|
else if (ev.button === 2) {
|
|
73912
74003
|
this.state.rightButtonDown = true;
|
|
74004
|
+
// Remember the mode we're panning out of so we can restore it on
|
|
74005
|
+
// release — otherwise aiAssist (and any non-idle mode) is lost.
|
|
74006
|
+
if (this.mode !== 'pan')
|
|
74007
|
+
this.modeBeforePan = this.mode;
|
|
73913
74008
|
this.setMode('pan');
|
|
73914
74009
|
}
|
|
73915
74010
|
// Route to external handler
|
|
@@ -73930,7 +74025,10 @@ void main() {
|
|
|
73930
74025
|
else if (ev.button === 2) {
|
|
73931
74026
|
this.state.rightButtonDown = false;
|
|
73932
74027
|
if (this.mode === 'pan') {
|
|
73933
|
-
|
|
74028
|
+
// Restore aiAssist if we panned out of it; otherwise idle (unchanged
|
|
74029
|
+
// behaviour for normal drawing modes).
|
|
74030
|
+
this.setMode(this.modeBeforePan === 'aiAssist' ? 'aiAssist' : 'idle');
|
|
74031
|
+
this.modeBeforePan = 'idle';
|
|
73934
74032
|
}
|
|
73935
74033
|
}
|
|
73936
74034
|
// Route to external handler
|
|
@@ -75853,6 +75951,474 @@ void main() {
|
|
|
75853
75951
|
}
|
|
75854
75952
|
}
|
|
75855
75953
|
|
|
75954
|
+
/**
|
|
75955
|
+
* AiAssistTool — interactive prompt-based segmentation (experimental).
|
|
75956
|
+
*
|
|
75957
|
+
* The clinician left-clicks foreground / background points (or drags a box /
|
|
75958
|
+
* scribbles) on the current slice; a backend model returns a region mask that
|
|
75959
|
+
* is written into an INDEPENDENT scratch MaskVolume (`aiAssistMaskVolume`,
|
|
75960
|
+
* registered as `maskData.volumes['aiScratch']`) — never the validated layers.
|
|
75961
|
+
*
|
|
75962
|
+
* Network lives in the app layer: this tool only (a) maps screen → voxel-slice
|
|
75963
|
+
* coordinates, (b) accumulates the prompt for the current slice, (c) fires
|
|
75964
|
+
* `onPrompt` so the app can call the backend, and (d) applies the returned mask
|
|
75965
|
+
* via `applyMask`. The app's `useAiAssist` composable wires (c)→(d).
|
|
75966
|
+
*
|
|
75967
|
+
* Sandbox: snapshot on enter (`snapshot`), then on exit either `discard`
|
|
75968
|
+
* (restore snapshot) or merge is handled by NrrdTools (clone + pushGroup).
|
|
75969
|
+
*
|
|
75970
|
+
* Coordinate mapping supports all three views (axial/sagittal/coronal); it
|
|
75971
|
+
* inverts the per-axis display transform of RenderingUtils.renderSliceToCanvas
|
|
75972
|
+
* (only coronal 'y' is vertically flipped). Merge captures painted voxels from
|
|
75973
|
+
* any view (the scratch is a full 3D volume).
|
|
75974
|
+
*/
|
|
75975
|
+
/** Scratch volume key in maskData.volumes — kept OUT of image.layers so
|
|
75976
|
+
* compositeAllLayers ignores it (we render it ourselves in start()). */
|
|
75977
|
+
const AI_SCRATCH_LAYER = "aiScratch";
|
|
75978
|
+
class AiAssistTool extends BaseTool {
|
|
75979
|
+
constructor(ctx, host) {
|
|
75980
|
+
super(ctx);
|
|
75981
|
+
/** Independent scratch volume (also registered at maskData.volumes['aiScratch']). */
|
|
75982
|
+
this.scratchVol = null;
|
|
75983
|
+
/** Snapshot of the scratch buffer taken on enter (for discard). */
|
|
75984
|
+
this.snapshotData = null;
|
|
75985
|
+
/** Frozen regions ("New region" commits here). Pixels recorded here survive a
|
|
75986
|
+
* later re-prediction — even on the SAME channel — so multiple separate regions
|
|
75987
|
+
* can be drawn without the newest prediction wiping the previous ones. Null =
|
|
75988
|
+
* nothing frozen yet (applySliceRle then behaves as a single live region). */
|
|
75989
|
+
this.committedVol = null;
|
|
75990
|
+
this.promptTool = "point";
|
|
75991
|
+
this.polarity = 1; // 1 = foreground (positive), 0 = background
|
|
75992
|
+
/** Target channel/label (1-8) the AI paints into — the "AI layer" channel. */
|
|
75993
|
+
this.channel = 1;
|
|
75994
|
+
/** Scribble brush radius (px), user-adjustable via slider. */
|
|
75995
|
+
this.scribbleSize = 5;
|
|
75996
|
+
/** Accumulated prompts for the CURRENT slice; reset on slice/axis change. */
|
|
75997
|
+
this.points = [];
|
|
75998
|
+
this.scribble = [];
|
|
75999
|
+
this.activeSlice = -1;
|
|
76000
|
+
// Drag state (box / scribble) — voxel coords for the prompt
|
|
76001
|
+
this.dragging = false;
|
|
76002
|
+
this.dragStart = null;
|
|
76003
|
+
// Screen-space (display px) copies for the LIVE preview drawn each frame.
|
|
76004
|
+
this.dragScreenStart = null;
|
|
76005
|
+
this.dragScreen = null;
|
|
76006
|
+
this.screenScribble = [];
|
|
76007
|
+
// Live cursor position (screen px) for the scribble brush-size preview ring —
|
|
76008
|
+
// updated on every hover move so the ring follows the mouse like the paint brush.
|
|
76009
|
+
this.hoverScreen = null;
|
|
76010
|
+
/** Fired when a prompt gesture completes — app calls backend, then applyMask(). */
|
|
76011
|
+
this.onPrompt = null;
|
|
76012
|
+
this.host = host;
|
|
76013
|
+
}
|
|
76014
|
+
// ── Configuration (driven from the panel via NrrdTools) ────────────────────
|
|
76015
|
+
setPromptTool(tool) {
|
|
76016
|
+
// Switching tool starts a clean gesture — drop any stale points/box/scribble
|
|
76017
|
+
// and drag state so the next click behaves predictably.
|
|
76018
|
+
if (tool !== this.promptTool) {
|
|
76019
|
+
this.resetPrompts();
|
|
76020
|
+
this.dragging = false;
|
|
76021
|
+
this.dragStart = null;
|
|
76022
|
+
}
|
|
76023
|
+
this.promptTool = tool;
|
|
76024
|
+
}
|
|
76025
|
+
setPolarity(label) { this.polarity = label === 0 ? 0 : 1; }
|
|
76026
|
+
setScribbleSize(size) {
|
|
76027
|
+
this.scribbleSize = Math.max(1, Math.min(40, Math.round(size)));
|
|
76028
|
+
}
|
|
76029
|
+
setChannel(channel) {
|
|
76030
|
+
const c = Math.max(1, Math.min(8, Math.round(channel)));
|
|
76031
|
+
// Changing channel starts a NEW region in the new colour — otherwise the
|
|
76032
|
+
// accumulated points would be re-predicted and repainted in the new label,
|
|
76033
|
+
// recolouring the previous channel's region.
|
|
76034
|
+
if (c !== this.channel)
|
|
76035
|
+
this.resetPrompts();
|
|
76036
|
+
this.channel = c;
|
|
76037
|
+
}
|
|
76038
|
+
getChannel() { return this.channel; }
|
|
76039
|
+
getScratchVolume() { return this.scratchVol; }
|
|
76040
|
+
/** Serialize the scratch volume as per-z-slice RLE (only NON-empty slices),
|
|
76041
|
+
* for persisting to ai_generated_nii_LPS on the backend. Binary (any channel →
|
|
76042
|
+
* 1); RLE is alternating run lengths starting with a 0-run — the exact format
|
|
76043
|
+
* the backend's rle_decode expects. Uses getSliceUint8 (the same path the
|
|
76044
|
+
* clinician layer's /api/mask/replace uses) so orientation matches. */
|
|
76045
|
+
getScratchSlices() {
|
|
76046
|
+
if (!this.scratchVol)
|
|
76047
|
+
return null;
|
|
76048
|
+
const { depth } = this.scratchVol.getDimensions();
|
|
76049
|
+
const out = [];
|
|
76050
|
+
let width = 0;
|
|
76051
|
+
let height = 0;
|
|
76052
|
+
for (let z = 0; z < depth; z++) {
|
|
76053
|
+
const { data, width: w, height: h } = this.scratchVol.getSliceUint8(z, "z");
|
|
76054
|
+
width = w;
|
|
76055
|
+
height = h;
|
|
76056
|
+
// RLE-encode (binarized) in one pass; skip fully-empty slices.
|
|
76057
|
+
const runs = [];
|
|
76058
|
+
let value = 0; // always begin with a 0-run (matches backend rle_encode)
|
|
76059
|
+
let count = 0;
|
|
76060
|
+
let any = false;
|
|
76061
|
+
for (let i = 0; i < data.length; i++) {
|
|
76062
|
+
const v = data[i] !== 0 ? 1 : 0;
|
|
76063
|
+
if (v)
|
|
76064
|
+
any = true;
|
|
76065
|
+
if (v === value) {
|
|
76066
|
+
count++;
|
|
76067
|
+
}
|
|
76068
|
+
else {
|
|
76069
|
+
runs.push(count);
|
|
76070
|
+
value = v;
|
|
76071
|
+
count = 1;
|
|
76072
|
+
}
|
|
76073
|
+
}
|
|
76074
|
+
runs.push(count);
|
|
76075
|
+
if (any)
|
|
76076
|
+
out.push({ sliceIndex: z, rle: runs });
|
|
76077
|
+
}
|
|
76078
|
+
return { axis: "z", width, height, slices: out };
|
|
76079
|
+
}
|
|
76080
|
+
// ── Sandbox lifecycle ──────────────────────────────────────────────────────
|
|
76081
|
+
/** Create (or reuse) the scratch volume sized to the layer grid and register
|
|
76082
|
+
* it under maskData.volumes['aiScratch']. Snapshots the empty buffer. */
|
|
76083
|
+
enter() {
|
|
76084
|
+
const volumes = this.ctx.protectedData.maskData.volumes;
|
|
76085
|
+
const baseId = this.ctx.nrrd_states.image.layers[0];
|
|
76086
|
+
const base = volumes[baseId];
|
|
76087
|
+
if (!base)
|
|
76088
|
+
return;
|
|
76089
|
+
const d = base.getDimensions();
|
|
76090
|
+
if (!this.scratchVol ||
|
|
76091
|
+
this.scratchVol.getDimensions().width !== d.width ||
|
|
76092
|
+
this.scratchVol.getDimensions().height !== d.height ||
|
|
76093
|
+
this.scratchVol.getDimensions().depth !== d.depth) {
|
|
76094
|
+
this.scratchVol = new MaskVolume(d.width, d.height, d.depth, 1);
|
|
76095
|
+
}
|
|
76096
|
+
else {
|
|
76097
|
+
this.scratchVol.clear();
|
|
76098
|
+
}
|
|
76099
|
+
volumes[AI_SCRATCH_LAYER] = this.scratchVol;
|
|
76100
|
+
// Paint the AI scratch with the AI-Assist palette (channel 1 = cyan) so the 2D
|
|
76101
|
+
// overlay matches the cyan ai_generated GLB. Scoped to THIS volume only — the
|
|
76102
|
+
// global palette / clinician mask colours are untouched.
|
|
76103
|
+
for (let ch = 1; ch <= 8; ch++) {
|
|
76104
|
+
const c = AI_MASK_CHANNEL_COLORS[ch];
|
|
76105
|
+
if (c)
|
|
76106
|
+
this.scratchVol.setChannelColor(ch, { r: c.r, g: c.g, b: c.b, a: c.a });
|
|
76107
|
+
}
|
|
76108
|
+
this.snapshotData = this.scratchVol.getRawData().slice();
|
|
76109
|
+
this.committedVol = null; // nothing frozen at session start
|
|
76110
|
+
this.resetPrompts();
|
|
76111
|
+
}
|
|
76112
|
+
/** Remove the scratch volume from the registry and drop references. */
|
|
76113
|
+
exit() {
|
|
76114
|
+
const volumes = this.ctx.protectedData.maskData.volumes;
|
|
76115
|
+
delete volumes[AI_SCRATCH_LAYER];
|
|
76116
|
+
this.scratchVol = null;
|
|
76117
|
+
this.snapshotData = null;
|
|
76118
|
+
this.committedVol = null;
|
|
76119
|
+
this.resetPrompts();
|
|
76120
|
+
this.dragging = false;
|
|
76121
|
+
this.dragStart = null;
|
|
76122
|
+
}
|
|
76123
|
+
/** Discard everything painted since enter() — restore the snapshot. */
|
|
76124
|
+
discard() {
|
|
76125
|
+
if (this.scratchVol && this.snapshotData) {
|
|
76126
|
+
this.scratchVol.setRawData(this.snapshotData.slice());
|
|
76127
|
+
}
|
|
76128
|
+
this.committedVol = null; // discard wipes frozen regions too
|
|
76129
|
+
this.resetPrompts();
|
|
76130
|
+
}
|
|
76131
|
+
/** "New region": freeze every voxel painted so far so it survives later
|
|
76132
|
+
* re-predictions (even on the same channel), then start a fresh prompt set.
|
|
76133
|
+
* This is what makes drawing multiple separate regions actually work — without
|
|
76134
|
+
* it the next prediction's same-channel cleanup erases the previous region. */
|
|
76135
|
+
commitRegion() {
|
|
76136
|
+
if (this.scratchVol) {
|
|
76137
|
+
const d = this.scratchVol.getDimensions();
|
|
76138
|
+
if (!this.committedVol ||
|
|
76139
|
+
this.committedVol.getDimensions().width !== d.width ||
|
|
76140
|
+
this.committedVol.getDimensions().height !== d.height ||
|
|
76141
|
+
this.committedVol.getDimensions().depth !== d.depth) {
|
|
76142
|
+
this.committedVol = new MaskVolume(d.width, d.height, d.depth, 1);
|
|
76143
|
+
}
|
|
76144
|
+
this.committedVol.setRawData(this.scratchVol.getRawData().slice());
|
|
76145
|
+
}
|
|
76146
|
+
this.resetPrompts();
|
|
76147
|
+
}
|
|
76148
|
+
/** Clear the in-progress prompt set (e.g. slice/axis change). Does NOT freeze —
|
|
76149
|
+
* use commitRegion() for the "New region" action. */
|
|
76150
|
+
resetPrompts() {
|
|
76151
|
+
this.points = [];
|
|
76152
|
+
this.scribble = [];
|
|
76153
|
+
this.box = undefined;
|
|
76154
|
+
this.dragScreenStart = null;
|
|
76155
|
+
this.dragScreen = null;
|
|
76156
|
+
this.screenScribble = [];
|
|
76157
|
+
}
|
|
76158
|
+
// ── Coordinate mapping (all three views) ───────────────────────────────────
|
|
76159
|
+
//
|
|
76160
|
+
// Mapping must INVERT exactly what RenderingUtils.renderSliceToCanvas does to
|
|
76161
|
+
// display the overlay (it is the renderer used by renderOverlay):
|
|
76162
|
+
// - axial (z) / sagittal (x): no flip
|
|
76163
|
+
// - coronal (y): vertical flip (scale(1,-1))
|
|
76164
|
+
// Slice voxel dims & canvas mm extents per axis (matches MaskVolume
|
|
76165
|
+
// getSliceDimensions + SphereTool.setSphereCanvasSize):
|
|
76166
|
+
// z → (W=width, H=height) over (x_mm, y_mm)
|
|
76167
|
+
// y → (W=width, H=depth ) over (x_mm, z_mm) [vertical flip]
|
|
76168
|
+
// x → (W=depth, H=height) over (z_mm, y_mm)
|
|
76169
|
+
/** Voxel-slice (width,height) for the current axis. */
|
|
76170
|
+
sliceDims() {
|
|
76171
|
+
if (!this.scratchVol)
|
|
76172
|
+
return null;
|
|
76173
|
+
const d = this.scratchVol.getDimensions();
|
|
76174
|
+
switch (this.ctx.protectedData.axis) {
|
|
76175
|
+
case "z": return { w: d.width, h: d.height };
|
|
76176
|
+
case "y": return { w: d.width, h: d.depth };
|
|
76177
|
+
case "x": return { w: d.depth, h: d.height };
|
|
76178
|
+
default: return null;
|
|
76179
|
+
}
|
|
76180
|
+
}
|
|
76181
|
+
/** Screen offset → voxel-slice (vx,vy) for the current axis. */
|
|
76182
|
+
toVoxel(e) {
|
|
76183
|
+
const sd = this.sliceDims();
|
|
76184
|
+
if (!sd || !this.scratchVol)
|
|
76185
|
+
return null;
|
|
76186
|
+
const nrrd = this.ctx.nrrd_states;
|
|
76187
|
+
const img = nrrd.image;
|
|
76188
|
+
const mx = e.offsetX / nrrd.view.sizeFactor;
|
|
76189
|
+
const my = e.offsetY / nrrd.view.sizeFactor;
|
|
76190
|
+
let hMM, vMM, flipV;
|
|
76191
|
+
switch (this.ctx.protectedData.axis) {
|
|
76192
|
+
case "z":
|
|
76193
|
+
hMM = img.nrrd_x_mm;
|
|
76194
|
+
vMM = img.nrrd_y_mm;
|
|
76195
|
+
flipV = false;
|
|
76196
|
+
break;
|
|
76197
|
+
case "y":
|
|
76198
|
+
hMM = img.nrrd_x_mm;
|
|
76199
|
+
vMM = img.nrrd_z_mm;
|
|
76200
|
+
flipV = true;
|
|
76201
|
+
break;
|
|
76202
|
+
case "x":
|
|
76203
|
+
hMM = img.nrrd_z_mm;
|
|
76204
|
+
vMM = img.nrrd_y_mm;
|
|
76205
|
+
flipV = false;
|
|
76206
|
+
break;
|
|
76207
|
+
default: return null;
|
|
76208
|
+
}
|
|
76209
|
+
const vx = Math.round(mx * sd.w / hMM);
|
|
76210
|
+
let vy = Math.round(my * sd.h / vMM);
|
|
76211
|
+
if (flipV)
|
|
76212
|
+
vy = sd.h - 1 - vy;
|
|
76213
|
+
if (vx < 0 || vx >= sd.w || vy < 0 || vy >= sd.h)
|
|
76214
|
+
return null;
|
|
76215
|
+
return { vx, vy, w: sd.w, h: sd.h };
|
|
76216
|
+
}
|
|
76217
|
+
// ── Pointer handlers (left button; right stays pan in EventRouter) ──────────
|
|
76218
|
+
onPointerDown(e) {
|
|
76219
|
+
const hit = this.toVoxel(e);
|
|
76220
|
+
if (!hit)
|
|
76221
|
+
return;
|
|
76222
|
+
this.syncSlice();
|
|
76223
|
+
if (this.promptTool === "point") {
|
|
76224
|
+
this.points.push({ x: hit.vx, y: hit.vy, label: this.polarity });
|
|
76225
|
+
this.emit();
|
|
76226
|
+
}
|
|
76227
|
+
else {
|
|
76228
|
+
// box / scribble: begin a drag
|
|
76229
|
+
this.dragging = true;
|
|
76230
|
+
this.dragStart = { x: hit.vx, y: hit.vy };
|
|
76231
|
+
this.dragScreenStart = { x: e.offsetX, y: e.offsetY };
|
|
76232
|
+
this.dragScreen = { x: e.offsetX, y: e.offsetY };
|
|
76233
|
+
this.screenScribble = [];
|
|
76234
|
+
if (this.promptTool === "scribble") {
|
|
76235
|
+
this.scribble.push({ x: hit.vx, y: hit.vy, label: this.polarity });
|
|
76236
|
+
this.screenScribble.push({ x: e.offsetX, y: e.offsetY });
|
|
76237
|
+
}
|
|
76238
|
+
}
|
|
76239
|
+
}
|
|
76240
|
+
onPointerMove(e) {
|
|
76241
|
+
// Track hover for the scribble preview ring even when not drawing.
|
|
76242
|
+
this.hoverScreen = { x: e.offsetX, y: e.offsetY };
|
|
76243
|
+
if (!this.dragging)
|
|
76244
|
+
return;
|
|
76245
|
+
// Track screen pos first so the live preview follows even slightly out of bounds.
|
|
76246
|
+
this.dragScreen = { x: e.offsetX, y: e.offsetY };
|
|
76247
|
+
const hit = this.toVoxel(e);
|
|
76248
|
+
if (!hit)
|
|
76249
|
+
return;
|
|
76250
|
+
if (this.promptTool === "scribble") {
|
|
76251
|
+
this.scribble.push({ x: hit.vx, y: hit.vy, label: this.polarity });
|
|
76252
|
+
this.screenScribble.push({ x: e.offsetX, y: e.offsetY });
|
|
76253
|
+
}
|
|
76254
|
+
else if (this.promptTool === "box" && this.dragStart) {
|
|
76255
|
+
this.box = {
|
|
76256
|
+
x0: this.dragStart.x, y0: this.dragStart.y,
|
|
76257
|
+
x1: hit.vx, y1: hit.vy,
|
|
76258
|
+
label: this.polarity, // foreground box grows within; background box excludes
|
|
76259
|
+
};
|
|
76260
|
+
}
|
|
76261
|
+
}
|
|
76262
|
+
onPointerUp(_e) {
|
|
76263
|
+
if (!this.dragging)
|
|
76264
|
+
return;
|
|
76265
|
+
this.dragging = false;
|
|
76266
|
+
this.emit();
|
|
76267
|
+
this.dragStart = null;
|
|
76268
|
+
this.dragScreenStart = null;
|
|
76269
|
+
this.dragScreen = null;
|
|
76270
|
+
this.screenScribble = [];
|
|
76271
|
+
}
|
|
76272
|
+
/** Reset the accumulated prompt set when the user scrubs to a new slice. */
|
|
76273
|
+
syncSlice() {
|
|
76274
|
+
const slice = this.ctx.nrrd_states.view.currentSliceIndex;
|
|
76275
|
+
if (slice !== this.activeSlice) {
|
|
76276
|
+
this.activeSlice = slice;
|
|
76277
|
+
this.resetPrompts();
|
|
76278
|
+
}
|
|
76279
|
+
}
|
|
76280
|
+
emit() {
|
|
76281
|
+
if (!this.onPrompt)
|
|
76282
|
+
return;
|
|
76283
|
+
const sd = this.sliceDims();
|
|
76284
|
+
if (!sd)
|
|
76285
|
+
return;
|
|
76286
|
+
this.onPrompt({
|
|
76287
|
+
axis: this.ctx.protectedData.axis,
|
|
76288
|
+
sliceIndex: this.ctx.nrrd_states.view.currentSliceIndex,
|
|
76289
|
+
width: sd.w,
|
|
76290
|
+
height: sd.h,
|
|
76291
|
+
points: [...this.points],
|
|
76292
|
+
box: this.box,
|
|
76293
|
+
scribble: this.scribble.length ? [...this.scribble] : undefined,
|
|
76294
|
+
scribbleRadius: this.scribbleSize,
|
|
76295
|
+
});
|
|
76296
|
+
}
|
|
76297
|
+
// ── Apply backend result → scratch volume ──────────────────────────────────
|
|
76298
|
+
applyMask(result) {
|
|
76299
|
+
if (!this.scratchVol)
|
|
76300
|
+
return;
|
|
76301
|
+
// 3D result (engine B): write each slice across the covered span.
|
|
76302
|
+
if (result.slices && result.sliceRange) {
|
|
76303
|
+
const [lo] = result.sliceRange;
|
|
76304
|
+
for (let k = 0; k < result.slices.length; k++) {
|
|
76305
|
+
this.applySliceRle(result.slices[k], result.axis, lo + k, result.width, result.height);
|
|
76306
|
+
}
|
|
76307
|
+
return;
|
|
76308
|
+
}
|
|
76309
|
+
// 2D result (engines mock / regiongrow / medsam2d): single slice.
|
|
76310
|
+
this.applySliceRle(result.rle, result.axis, result.sliceIndex, result.width, result.height);
|
|
76311
|
+
}
|
|
76312
|
+
/** Merge one RLE-encoded predicted slice into the scratch volume at sliceIndex.
|
|
76313
|
+
* Predicted pixels → current channel; stale current-channel pixels cleared
|
|
76314
|
+
* UNLESS they belong to a frozen (committed) region; OTHER channels preserved. */
|
|
76315
|
+
applySliceRle(rle, axis, sliceIndex, width, height) {
|
|
76316
|
+
if (!this.scratchVol)
|
|
76317
|
+
return;
|
|
76318
|
+
const predicted = new Uint8Array(width * height);
|
|
76319
|
+
let pos = 0;
|
|
76320
|
+
let value = 0;
|
|
76321
|
+
for (const run of rle) {
|
|
76322
|
+
if (value === 1 && run > 0) {
|
|
76323
|
+
const end = Math.min(pos + run, predicted.length);
|
|
76324
|
+
for (let i = pos; i < end; i++)
|
|
76325
|
+
predicted[i] = 1;
|
|
76326
|
+
}
|
|
76327
|
+
pos += run;
|
|
76328
|
+
value ^= 1;
|
|
76329
|
+
}
|
|
76330
|
+
const ch = this.channel;
|
|
76331
|
+
try {
|
|
76332
|
+
const out = this.scratchVol.getSliceUint8(sliceIndex, axis).data; // copy
|
|
76333
|
+
// Frozen same-channel pixels for this slice (if any region was committed).
|
|
76334
|
+
let frozen = null;
|
|
76335
|
+
if (this.committedVol) {
|
|
76336
|
+
try {
|
|
76337
|
+
frozen = this.committedVol.getSliceUint8(sliceIndex, axis).data;
|
|
76338
|
+
}
|
|
76339
|
+
catch (_a) {
|
|
76340
|
+
frozen = null;
|
|
76341
|
+
}
|
|
76342
|
+
}
|
|
76343
|
+
const n = Math.min(out.length, predicted.length);
|
|
76344
|
+
for (let i = 0; i < n; i++) {
|
|
76345
|
+
if (predicted[i])
|
|
76346
|
+
out[i] = ch;
|
|
76347
|
+
// Clear stale LIVE same-channel pixels (refinement), but never erase a
|
|
76348
|
+
// frozen region committed via "New region".
|
|
76349
|
+
else if (out[i] === ch && !(frozen && frozen[i] === ch))
|
|
76350
|
+
out[i] = 0;
|
|
76351
|
+
}
|
|
76352
|
+
this.scratchVol.setSliceUint8(sliceIndex, out, axis);
|
|
76353
|
+
}
|
|
76354
|
+
catch (_b) {
|
|
76355
|
+
// slice out of bounds / dims changed — ignore
|
|
76356
|
+
}
|
|
76357
|
+
}
|
|
76358
|
+
// ── Overlay render (called from DrawToolCore.start() while in aiAssist) ──────
|
|
76359
|
+
renderOverlay(targetCtx) {
|
|
76360
|
+
if (!this.scratchVol)
|
|
76361
|
+
return;
|
|
76362
|
+
const axis = this.ctx.protectedData.axis;
|
|
76363
|
+
const slice = this.ctx.nrrd_states.view.currentSliceIndex;
|
|
76364
|
+
const buffer = this.host.getOrCreateSliceBuffer(axis);
|
|
76365
|
+
if (!buffer)
|
|
76366
|
+
return;
|
|
76367
|
+
try {
|
|
76368
|
+
this.host.renderSliceToCanvas(AI_SCRATCH_LAYER, axis, slice, buffer, targetCtx, this.ctx.nrrd_states.view.changedWidth, this.ctx.nrrd_states.view.changedHeight);
|
|
76369
|
+
}
|
|
76370
|
+
catch (_a) {
|
|
76371
|
+
// volume not ready / slice out of bounds
|
|
76372
|
+
}
|
|
76373
|
+
// Live drag preview (screen space) — rubber-band box / scribble stroke so the
|
|
76374
|
+
// user sees the gesture before the prediction lands on pointer-up.
|
|
76375
|
+
if (this.dragging && this.dragScreenStart && this.dragScreen) {
|
|
76376
|
+
targetCtx.save();
|
|
76377
|
+
// Green-ish for foreground (include), red-ish for background (exclude).
|
|
76378
|
+
const color = this.polarity === 1 ? "rgba(120,255,180,0.95)" : "rgba(255,120,120,0.95)";
|
|
76379
|
+
targetCtx.strokeStyle = color;
|
|
76380
|
+
if (this.promptTool === "box") {
|
|
76381
|
+
targetCtx.setLineDash([5, 4]);
|
|
76382
|
+
targetCtx.lineWidth = 1.5;
|
|
76383
|
+
const x = Math.min(this.dragScreenStart.x, this.dragScreen.x);
|
|
76384
|
+
const y = Math.min(this.dragScreenStart.y, this.dragScreen.y);
|
|
76385
|
+
const w = Math.abs(this.dragScreen.x - this.dragScreenStart.x);
|
|
76386
|
+
const h = Math.abs(this.dragScreen.y - this.dragScreenStart.y);
|
|
76387
|
+
targetCtx.strokeRect(x, y, w, h);
|
|
76388
|
+
}
|
|
76389
|
+
else if (this.promptTool === "scribble" && this.screenScribble.length > 1) {
|
|
76390
|
+
targetCtx.lineCap = "round";
|
|
76391
|
+
targetCtx.lineJoin = "round";
|
|
76392
|
+
// Stroke thickness reflects the scribble brush size (diameter ≈ 2·radius).
|
|
76393
|
+
targetCtx.lineWidth = Math.max(2, this.scribbleSize * 2);
|
|
76394
|
+
targetCtx.beginPath();
|
|
76395
|
+
targetCtx.moveTo(this.screenScribble[0].x, this.screenScribble[0].y);
|
|
76396
|
+
for (let i = 1; i < this.screenScribble.length; i++) {
|
|
76397
|
+
targetCtx.lineTo(this.screenScribble[i].x, this.screenScribble[i].y);
|
|
76398
|
+
}
|
|
76399
|
+
targetCtx.stroke();
|
|
76400
|
+
}
|
|
76401
|
+
targetCtx.restore();
|
|
76402
|
+
}
|
|
76403
|
+
// Scribble brush-size preview ring (when NOT dragging) — a circle centred on
|
|
76404
|
+
// the cursor whose radius = scribbleSize, so the user sees the brush size and
|
|
76405
|
+
// how the slider changes it (mirrors the paint brush's preview ring). Drawn
|
|
76406
|
+
// every frame from the live hover position by copper3d's render loop.
|
|
76407
|
+
if (this.promptTool === "scribble" && !this.dragging && this.hoverScreen) {
|
|
76408
|
+
targetCtx.save();
|
|
76409
|
+
const fg = this.polarity === 1;
|
|
76410
|
+
targetCtx.strokeStyle = fg ? "rgba(120,255,180,0.9)" : "rgba(255,120,120,0.9)";
|
|
76411
|
+
targetCtx.lineWidth = 1.5;
|
|
76412
|
+
if (!fg)
|
|
76413
|
+
targetCtx.setLineDash([4, 4]); // dashed for background (exclude), like the eraser
|
|
76414
|
+
targetCtx.beginPath();
|
|
76415
|
+
targetCtx.arc(this.hoverScreen.x, this.hoverScreen.y, Math.max(2, this.scribbleSize), 0, Math.PI * 2);
|
|
76416
|
+
targetCtx.stroke();
|
|
76417
|
+
targetCtx.restore();
|
|
76418
|
+
}
|
|
76419
|
+
}
|
|
76420
|
+
}
|
|
76421
|
+
|
|
75856
76422
|
/**
|
|
75857
76423
|
* DrawToolCore — Tool orchestration and event routing.
|
|
75858
76424
|
*
|
|
@@ -75973,6 +76539,10 @@ void main() {
|
|
|
75973
76539
|
setEmptyCanvasSize: (axis) => this.setEmptyCanvasSize(axis),
|
|
75974
76540
|
reloadMasksFromVolume: () => this.reloadMasksFromVolume(),
|
|
75975
76541
|
});
|
|
76542
|
+
this.aiAssistTool = new AiAssistTool(toolCtx, {
|
|
76543
|
+
renderSliceToCanvas: (layer, axis, sliceIndex, buffer, ctx, w, h) => this.renderer.renderSliceToCanvas(layer, axis, sliceIndex, buffer, ctx, w, h),
|
|
76544
|
+
getOrCreateSliceBuffer: (axis) => this.renderer.getOrCreateSliceBuffer(axis),
|
|
76545
|
+
});
|
|
75976
76546
|
}
|
|
75977
76547
|
initDrawToolCore() {
|
|
75978
76548
|
// Initialize EventRouter for centralized event handling
|
|
@@ -76079,6 +76649,8 @@ void main() {
|
|
|
76079
76649
|
// Block contrast toggle during crosshair, draw, or sphere (mutual exclusion)
|
|
76080
76650
|
if (this.eventRouter.isCrosshairEnabled() || this.eventRouter.getMode() === 'draw')
|
|
76081
76651
|
return;
|
|
76652
|
+
if (this.eventRouter.getMode() === 'aiAssist')
|
|
76653
|
+
return;
|
|
76082
76654
|
if (this.state.gui_states.mode.sphere)
|
|
76083
76655
|
return;
|
|
76084
76656
|
// Toggle contrast mode manually since it's on keyup
|
|
@@ -76092,6 +76664,10 @@ void main() {
|
|
|
76092
76664
|
});
|
|
76093
76665
|
// Register pointer handlers with EventRouter
|
|
76094
76666
|
this.eventRouter.setPointerMoveHandler((e) => {
|
|
76667
|
+
if (this.eventRouter.getMode() === 'aiAssist') {
|
|
76668
|
+
this.aiAssistTool.onPointerMove(e);
|
|
76669
|
+
return;
|
|
76670
|
+
}
|
|
76095
76671
|
if (this.drawingTool.isActive || this.panTool.isActive) {
|
|
76096
76672
|
this.drawingPrameters.handleOnDrawingMouseMove(e);
|
|
76097
76673
|
}
|
|
@@ -76106,6 +76682,14 @@ void main() {
|
|
|
76106
76682
|
}
|
|
76107
76683
|
});
|
|
76108
76684
|
this.eventRouter.setPointerUpHandler((e) => {
|
|
76685
|
+
if (this.eventRouter.getMode() === 'aiAssist') {
|
|
76686
|
+
if (e.button === 0)
|
|
76687
|
+
this.aiAssistTool.onPointerUp(e);
|
|
76688
|
+
// Right-button pan must still clean up (cursor/state) even in AI mode.
|
|
76689
|
+
else if (e.button === 2)
|
|
76690
|
+
this.panTool.onPointerUp(e, this.state.gui_states.viewConfig.defaultPaintCursor);
|
|
76691
|
+
return;
|
|
76692
|
+
}
|
|
76109
76693
|
// Restore Scroll:Zoom if we swapped to Scroll:Slice on drag-start.
|
|
76110
76694
|
// Must run outside the drawing-tool gate below: slice-drag doesn't
|
|
76111
76695
|
// flip any of those flags, so the gate would skip restoration.
|
|
@@ -76265,7 +76849,10 @@ void main() {
|
|
|
76265
76849
|
this.activeWheelMode = 'none';
|
|
76266
76850
|
}
|
|
76267
76851
|
if (e.button === 0) {
|
|
76268
|
-
if (this.eventRouter.getMode() === '
|
|
76852
|
+
if (this.eventRouter.getMode() === 'aiAssist') {
|
|
76853
|
+
this.aiAssistTool.onPointerDown(e);
|
|
76854
|
+
}
|
|
76855
|
+
else if (this.eventRouter.getMode() === 'draw') {
|
|
76269
76856
|
this.drawingTool.onPointerDown(e);
|
|
76270
76857
|
}
|
|
76271
76858
|
else if (this.eventRouter.isCrosshairEnabled()) {
|
|
@@ -76380,6 +76967,12 @@ void main() {
|
|
|
76380
76967
|
|| this.state.gui_states.mode.sphereEraser) {
|
|
76381
76968
|
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);
|
|
76382
76969
|
}
|
|
76970
|
+
// AI-assist scratch overlay — drawn each frame whenever an AI session is
|
|
76971
|
+
// active (scratch volume exists), so it stays visible even when crosshair
|
|
76972
|
+
// temporarily takes over the interaction mode for debugging.
|
|
76973
|
+
if (this.aiAssistTool.getScratchVolume()) {
|
|
76974
|
+
this.aiAssistTool.renderOverlay(this.state.protectedData.ctxes.drawingCtx);
|
|
76975
|
+
}
|
|
76383
76976
|
}
|
|
76384
76977
|
else {
|
|
76385
76978
|
this.redrawDisplayCanvas();
|
|
@@ -77984,6 +78577,13 @@ void main() {
|
|
|
77984
78577
|
this._calculatorActive = false;
|
|
77985
78578
|
/** Stored closure callbacks from gui.ts setupGui() */
|
|
77986
78579
|
this.guiCallbacks = null;
|
|
78580
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
78581
|
+
// 10b. AI Assist (experimental) — interactive prompt segmentation
|
|
78582
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
78583
|
+
/** Whether AI-assist mode is currently active. */
|
|
78584
|
+
this._aiAssistActive = false;
|
|
78585
|
+
/** Layer visibility snapshot taken on enter, restored on exit. */
|
|
78586
|
+
this._aiPrevVisibility = null;
|
|
77987
78587
|
this.container = container;
|
|
77988
78588
|
// Create shared state
|
|
77989
78589
|
const mainAreaContainer = document.createElement("div");
|
|
@@ -78151,6 +78751,13 @@ void main() {
|
|
|
78151
78751
|
var _a, _b, _c;
|
|
78152
78752
|
if (!this.guiCallbacks)
|
|
78153
78753
|
return;
|
|
78754
|
+
// While AI-assist owns the canvas, BLOCK switching to other tools — the user
|
|
78755
|
+
// must explicitly Exit AI Assist from its panel first. (The Operation panel
|
|
78756
|
+
// also disables its tool buttons via the AiAssist:ActiveChanged event, so this
|
|
78757
|
+
// is just a defensive guard.)
|
|
78758
|
+
if (this._aiAssistActive && mode !== "aiAssist") {
|
|
78759
|
+
return;
|
|
78760
|
+
}
|
|
78154
78761
|
const prevSphere = this.state.gui_states.mode.sphere;
|
|
78155
78762
|
const prevSphereBrush = this.state.gui_states.mode.sphereBrush;
|
|
78156
78763
|
const prevSphereEraser = this.state.gui_states.mode.sphereEraser;
|
|
@@ -78799,6 +79406,111 @@ void main() {
|
|
|
78799
79406
|
this.resetLayerCanvas();
|
|
78800
79407
|
this.reloadMasksFromVolume();
|
|
78801
79408
|
}
|
|
79409
|
+
isAiAssistActive() { return this._aiAssistActive; }
|
|
79410
|
+
/**
|
|
79411
|
+
* Enter AI-assist mode (sandbox): hides the existing layer masks so ONLY the
|
|
79412
|
+
* AI overlay is shown, takes canvas ownership (left-click = prompt), and creates
|
|
79413
|
+
* the scratch volume. Right-drag still pans; wheel/slider still scrub slices.
|
|
79414
|
+
* The hidden masks are restored on exit (merge writes into them first if asked).
|
|
79415
|
+
*/
|
|
79416
|
+
enterAiAssistMode() {
|
|
79417
|
+
var _a;
|
|
79418
|
+
if (this._aiAssistActive)
|
|
79419
|
+
return;
|
|
79420
|
+
this._aiAssistActive = true;
|
|
79421
|
+
this.dragOperator.removeDragMode();
|
|
79422
|
+
// Hide all layer masks (visibility=false survives recomposite on slice change).
|
|
79423
|
+
const vis = this.state.gui_states.layerChannel.layerVisibility;
|
|
79424
|
+
this._aiPrevVisibility = Object.assign({}, vis);
|
|
79425
|
+
for (const layerId of this.state.nrrd_states.image.layers)
|
|
79426
|
+
vis[layerId] = false;
|
|
79427
|
+
this.drawCore.renderer.compositeAllLayers();
|
|
79428
|
+
this.drawCore.aiAssistTool.enter();
|
|
79429
|
+
(_a = this.drawCore.eventRouter) === null || _a === void 0 ? void 0 : _a.setGuiTool("aiAssist");
|
|
79430
|
+
}
|
|
79431
|
+
/**
|
|
79432
|
+
* Exit AI-assist mode: drop the scratch volume, restore normal tooling AND the
|
|
79433
|
+
* layer-mask visibility hidden on enter. If the caller merged first, the merged
|
|
79434
|
+
* result is now part of the layer volume and reappears with the restored masks.
|
|
79435
|
+
*/
|
|
79436
|
+
exitAiAssistMode() {
|
|
79437
|
+
var _a;
|
|
79438
|
+
if (!this._aiAssistActive)
|
|
79439
|
+
return;
|
|
79440
|
+
this._aiAssistActive = false;
|
|
79441
|
+
this.drawCore.aiAssistTool.exit();
|
|
79442
|
+
this.dragOperator.configDragMode();
|
|
79443
|
+
(_a = this.drawCore.eventRouter) === null || _a === void 0 ? void 0 : _a.setGuiTool("pencil");
|
|
79444
|
+
if (this._aiPrevVisibility) {
|
|
79445
|
+
const vis = this.state.gui_states.layerChannel.layerVisibility;
|
|
79446
|
+
for (const k of Object.keys(this._aiPrevVisibility))
|
|
79447
|
+
vis[k] = this._aiPrevVisibility[k];
|
|
79448
|
+
this._aiPrevVisibility = null;
|
|
79449
|
+
}
|
|
79450
|
+
this.reloadMasksFromVolume();
|
|
79451
|
+
}
|
|
79452
|
+
// — Driver methods called by the app-layer composable —
|
|
79453
|
+
aiSetPromptTool(tool) { this.drawCore.aiAssistTool.setPromptTool(tool); }
|
|
79454
|
+
aiSetPolarity(label) { this.drawCore.aiAssistTool.setPolarity(label); }
|
|
79455
|
+
/** Set the AI-layer channel (1-8) the predictions paint into. */
|
|
79456
|
+
aiSetChannel(channel) { this.drawCore.aiAssistTool.setChannel(channel); }
|
|
79457
|
+
/** Set the scribble brush radius (px). */
|
|
79458
|
+
aiSetScribbleSize(size) { this.drawCore.aiAssistTool.setScribbleSize(size); }
|
|
79459
|
+
/** Register the callback invoked when a prompt gesture completes (app → backend). */
|
|
79460
|
+
aiOnPrompt(cb) { this.drawCore.aiAssistTool.onPrompt = cb; }
|
|
79461
|
+
/** Apply a backend mask result into the scratch volume (overlay repaints next frame). */
|
|
79462
|
+
aiApplyMask(result) { this.drawCore.aiAssistTool.applyMask(result); }
|
|
79463
|
+
/** Clear the in-progress prompt set (e.g. slice change). */
|
|
79464
|
+
aiClearPrompts() { this.drawCore.aiAssistTool.resetPrompts(); }
|
|
79465
|
+
/** "New region": freeze current regions (they persist) + start a fresh prompt set. */
|
|
79466
|
+
aiCommitRegion() { this.drawCore.aiAssistTool.commitRegion(); }
|
|
79467
|
+
/** Discard all AI scratch painting since enter (sandbox discard). */
|
|
79468
|
+
aiDiscard() { this.drawCore.aiAssistTool.discard(); }
|
|
79469
|
+
/** True if the scratch volume holds any predicted voxels. */
|
|
79470
|
+
aiHasData() { var _a, _b; return (_b = (_a = this.drawCore.aiAssistTool.getScratchVolume()) === null || _a === void 0 ? void 0 : _a.hasData()) !== null && _b !== void 0 ? _b : false; }
|
|
79471
|
+
/** Serialize the AI scratch as per-slice RLE for persisting to ai_generated_nii_LPS. */
|
|
79472
|
+
aiGetScratchSlices() {
|
|
79473
|
+
return this.drawCore.aiAssistTool.getScratchSlices();
|
|
79474
|
+
}
|
|
79475
|
+
/**
|
|
79476
|
+
* Merge the AI scratch into a target layer as a single undoable group (sandbox
|
|
79477
|
+
* merge — best-practice: non-destructive + one Ctrl+Z). The scratch's channel
|
|
79478
|
+
* labels are PRESERVED (each AI channel maps onto the same channel of the
|
|
79479
|
+
* target layer). Scans all z-slices, so voxels painted from any view are caught.
|
|
79480
|
+
*/
|
|
79481
|
+
aiCommitToLayer(targetLayer = "layer1") {
|
|
79482
|
+
const scratch = this.drawCore.aiAssistTool.getScratchVolume();
|
|
79483
|
+
if (!scratch)
|
|
79484
|
+
return;
|
|
79485
|
+
const target = this.state.protectedData.maskData.volumes[targetLayer];
|
|
79486
|
+
if (!target)
|
|
79487
|
+
return;
|
|
79488
|
+
const dims = scratch.getDimensions();
|
|
79489
|
+
const deltas = [];
|
|
79490
|
+
for (let z = 0; z < dims.depth; z++) {
|
|
79491
|
+
const sc = scratch.getSliceUint8(z, "z").data;
|
|
79492
|
+
let any = false;
|
|
79493
|
+
for (let i = 0; i < sc.length; i++) {
|
|
79494
|
+
if (sc[i] !== 0) {
|
|
79495
|
+
any = true;
|
|
79496
|
+
break;
|
|
79497
|
+
}
|
|
79498
|
+
}
|
|
79499
|
+
if (!any)
|
|
79500
|
+
continue;
|
|
79501
|
+
const oldSlice = target.getSliceUint8(z, "z").data; // copy
|
|
79502
|
+
const newSlice = oldSlice.slice();
|
|
79503
|
+
for (let i = 0; i < newSlice.length; i++) {
|
|
79504
|
+
if (sc[i] !== 0)
|
|
79505
|
+
newSlice[i] = sc[i]; // preserve the AI channel label
|
|
79506
|
+
}
|
|
79507
|
+
target.setSliceUint8(z, newSlice, "z");
|
|
79508
|
+
deltas.push({ layerId: targetLayer, axis: "z", sliceIndex: z, oldSlice, newSlice: newSlice.slice() });
|
|
79509
|
+
}
|
|
79510
|
+
if (deltas.length)
|
|
79511
|
+
this.drawCore.undoManager.pushGroup(deltas);
|
|
79512
|
+
this.reloadMasksFromVolume();
|
|
79513
|
+
}
|
|
78802
79514
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
78803
79515
|
// 11. Clear / Reset
|
|
78804
79516
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -79162,9 +79874,11 @@ void main() {
|
|
|
79162
79874
|
}
|
|
79163
79875
|
|
|
79164
79876
|
// import * as kiwrious from "copper3d_plugin_heart_k";
|
|
79165
|
-
const REVISION = "v3.
|
|
79877
|
+
const REVISION = "v3.5.0-beta";
|
|
79166
79878
|
console.log(`%cCopper3D Visualisation %cBeta:${REVISION}`, "padding: 3px;color:white; background:#023047", "padding: 3px;color:white; background:#f50a25");
|
|
79167
79879
|
|
|
79880
|
+
exports.AI_CHANNEL_HEX_COLORS = AI_CHANNEL_HEX_COLORS;
|
|
79881
|
+
exports.AI_MASK_CHANNEL_COLORS = AI_MASK_CHANNEL_COLORS;
|
|
79168
79882
|
exports.CHANNEL_COLORS = CHANNEL_COLORS;
|
|
79169
79883
|
exports.CHANNEL_HEX_COLORS = CHANNEL_HEX_COLORS;
|
|
79170
79884
|
exports.CameraViewPoint = CameraViewPoint;
|