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.
Files changed (45) hide show
  1. package/dist/Renderer/baseRenderer.d.ts +3 -0
  2. package/dist/Renderer/baseRenderer.js +20 -0
  3. package/dist/Renderer/baseRenderer.js.map +1 -1
  4. package/dist/Renderer/copperMSceneRenderer.d.ts +3 -0
  5. package/dist/Renderer/copperMSceneRenderer.js +22 -0
  6. package/dist/Renderer/copperMSceneRenderer.js.map +1 -1
  7. package/dist/Renderer/copperRenderer.d.ts +0 -2
  8. package/dist/Renderer/copperRenderer.js +0 -5
  9. package/dist/Renderer/copperRenderer.js.map +1 -1
  10. package/dist/Utils/segmentation/DrawToolCore.d.ts +3 -0
  11. package/dist/Utils/segmentation/DrawToolCore.js +29 -1
  12. package/dist/Utils/segmentation/DrawToolCore.js.map +1 -1
  13. package/dist/Utils/segmentation/NrrdTools.d.ts +54 -0
  14. package/dist/Utils/segmentation/NrrdTools.js +119 -0
  15. package/dist/Utils/segmentation/NrrdTools.js.map +1 -1
  16. package/dist/Utils/segmentation/core/index.d.ts +1 -1
  17. package/dist/Utils/segmentation/core/index.js +1 -1
  18. package/dist/Utils/segmentation/core/index.js.map +1 -1
  19. package/dist/Utils/segmentation/core/types.d.ts +11 -1
  20. package/dist/Utils/segmentation/core/types.js +30 -0
  21. package/dist/Utils/segmentation/core/types.js.map +1 -1
  22. package/dist/Utils/segmentation/eventRouter/EventRouter.d.ts +2 -0
  23. package/dist/Utils/segmentation/eventRouter/EventRouter.js +37 -6
  24. package/dist/Utils/segmentation/eventRouter/EventRouter.js.map +1 -1
  25. package/dist/Utils/segmentation/eventRouter/types.d.ts +3 -2
  26. package/dist/Utils/segmentation/tools/AiAssistTool.d.ts +146 -0
  27. package/dist/Utils/segmentation/tools/AiAssistTool.js +470 -0
  28. package/dist/Utils/segmentation/tools/AiAssistTool.js.map +1 -0
  29. package/dist/bundle.esm.js +726 -14
  30. package/dist/bundle.umd.js +727 -13
  31. package/dist/index.d.ts +5 -4
  32. package/dist/index.js +3 -3
  33. package/dist/index.js.map +1 -1
  34. package/dist/types/Renderer/baseRenderer.d.ts +3 -0
  35. package/dist/types/Renderer/copperMSceneRenderer.d.ts +3 -0
  36. package/dist/types/Renderer/copperRenderer.d.ts +0 -2
  37. package/dist/types/Utils/segmentation/DrawToolCore.d.ts +3 -0
  38. package/dist/types/Utils/segmentation/NrrdTools.d.ts +54 -0
  39. package/dist/types/Utils/segmentation/core/index.d.ts +1 -1
  40. package/dist/types/Utils/segmentation/core/types.d.ts +11 -1
  41. package/dist/types/Utils/segmentation/eventRouter/EventRouter.d.ts +2 -0
  42. package/dist/types/Utils/segmentation/eventRouter/types.d.ts +3 -2
  43. package/dist/types/Utils/segmentation/tools/AiAssistTool.d.ts +146 -0
  44. package/dist/types/index.d.ts +5 -4
  45. package/package.json +1 -1
@@ -50086,6 +50086,7 @@ var Stats$1 = Stats;
50086
50086
  class baseRenderer {
50087
50087
  constructor(container, options) {
50088
50088
  var _a, _b, _c, _d, _e;
50089
+ this.running = true;
50089
50090
  this.visualCtrls = [];
50090
50091
  this.container = container;
50091
50092
  this.options = options;
@@ -50186,6 +50187,25 @@ class baseRenderer {
50186
50187
  setClearColor(clearColor = 0x000000, alpha = 0) {
50187
50188
  this.renderer.setClearColor(clearColor, alpha);
50188
50189
  }
50190
+ // Stop the render loop (cancels the requestAnimationFrame chain).
50191
+ stop() {
50192
+ this.running = false;
50193
+ }
50194
+ // Full teardown: stop the loop, free the GPU resources, release the WebGL context
50195
+ // (browsers cap live contexts ~16), and remove the canvas. Call on page unmount.
50196
+ dispose() {
50197
+ var _a, _b;
50198
+ this.running = false;
50199
+ try {
50200
+ (_a = this.pmremGenerator) === null || _a === void 0 ? void 0 : _a.dispose();
50201
+ this.renderer.dispose();
50202
+ this.renderer.forceContextLoss();
50203
+ }
50204
+ catch (e) {
50205
+ /* ignore */
50206
+ }
50207
+ (_b = this.renderer.domElement) === null || _b === void 0 ? void 0 : _b.remove();
50208
+ }
50189
50209
  addGui() {
50190
50210
  var _a, _b, _c;
50191
50211
  const gui = (this.gui = new GUI$1({
@@ -59351,7 +59371,6 @@ class copperRenderer extends baseRenderer {
59351
59371
  this.delta = 0;
59352
59372
  this.interval = 1 / this.fps;
59353
59373
  this.preRenderCallbackFunctions = [];
59354
- this.running = true;
59355
59374
  this.animate = (time) => {
59356
59375
  var _a, _b, _c;
59357
59376
  if (!this.running)
@@ -59382,10 +59401,6 @@ class copperRenderer extends baseRenderer {
59382
59401
  }
59383
59402
  };
59384
59403
  }
59385
- // Stop the render loop (call on teardown to release the RAF and let GC reclaim).
59386
- stop() {
59387
- this.running = false;
59388
- }
59389
59404
  getSceneByName(name) {
59390
59405
  return this.sceneMap[name];
59391
59406
  }
@@ -59910,6 +59925,7 @@ class copperMSceneRenderer {
59910
59925
  alpha: true,
59911
59926
  antialias: true,
59912
59927
  });
59928
+ this.running = true;
59913
59929
  this.renderSceneInfo = (sceneInfo) => {
59914
59930
  const elem = sceneInfo.container;
59915
59931
  // get the viewpoint relative position of this element
@@ -59948,6 +59964,8 @@ class copperMSceneRenderer {
59948
59964
  }
59949
59965
  };
59950
59966
  this.animate = () => {
59967
+ if (!this.running)
59968
+ return;
59951
59969
  const clearColor = new Color$1("#000");
59952
59970
  this.renderer.setScissorTest(false);
59953
59971
  this.renderer.setClearColor(clearColor, 0);
@@ -60013,6 +60031,25 @@ class copperMSceneRenderer {
60013
60031
  }, undefined, reject);
60014
60032
  });
60015
60033
  }
60034
+ stop() {
60035
+ this.running = false;
60036
+ }
60037
+ // Full teardown: stop the loop, free GPU resources, release the WebGL context, and
60038
+ // remove the canvas. Call on page unmount to avoid context exhaustion / leaks.
60039
+ dispose() {
60040
+ var _a, _b;
60041
+ this.running = false;
60042
+ try {
60043
+ (_a = this.pmremGenerator) === null || _a === void 0 ? void 0 : _a.dispose();
60044
+ this.renderer.dispose();
60045
+ this.renderer.forceContextLoss();
60046
+ }
60047
+ catch (e) {
60048
+ /* ignore */
60049
+ }
60050
+ (_b = this.canvas) === null || _b === void 0 ? void 0 : _b.remove();
60051
+ this.elems.forEach((el) => el.remove());
60052
+ }
60016
60053
  }
60017
60054
 
60018
60055
  /******************************************************************************
@@ -70432,6 +70469,36 @@ const CHANNEL_HEX_COLORS = {
70432
70469
  7: '#f97316',
70433
70470
  8: '#8b5cf6', // Violet
70434
70471
  };
70472
+ /**
70473
+ * AI-Assist channel palette. Identical to the default palette EXCEPT:
70474
+ * - channel 1 = cyan (the AI-Assist accent #5ec8ff), so the 2D AI overlay aligns
70475
+ * with the cyan ai_generated GLB in the 3D panel;
70476
+ * - channel 6 takes the emerald that channel 1 vacated, so cyan isn't duplicated.
70477
+ * Applied ONLY to the AI scratch layer (set per-volume in AiAssistTool.enter) —
70478
+ * the global palette above is untouched, so the clinician mask stays emerald.
70479
+ */
70480
+ const AI_MASK_CHANNEL_COLORS = {
70481
+ 0: { r: 0, g: 0, b: 0, a: 0 },
70482
+ 1: { r: 94, g: 200, b: 255, a: 255 },
70483
+ 2: { r: 244, g: 63, b: 94, a: 255 },
70484
+ 3: { r: 59, g: 130, b: 246, a: 255 },
70485
+ 4: { r: 251, g: 191, b: 36, a: 255 },
70486
+ 5: { r: 217, g: 70, b: 239, a: 255 },
70487
+ 6: { r: 16, g: 185, b: 129, a: 255 },
70488
+ 7: { r: 249, g: 115, b: 22, a: 255 },
70489
+ 8: { r: 139, g: 92, b: 246, a: 255 }, // Violet
70490
+ };
70491
+ const AI_CHANNEL_HEX_COLORS = {
70492
+ 0: '#000000',
70493
+ 1: '#5ec8ff',
70494
+ 2: '#f43f5e',
70495
+ 3: '#3b82f6',
70496
+ 4: '#fbbf24',
70497
+ 5: '#d946ef',
70498
+ 6: '#10b981',
70499
+ 7: '#f97316',
70500
+ 8: '#8b5cf6', // Violet
70501
+ };
70435
70502
  // ── Color Conversion Utilities ──────────────────────────────────────────
70436
70503
  /**
70437
70504
  * Convert an RGBAColor to a hex string (no alpha), e.g. '#ff0000'.
@@ -73580,6 +73647,8 @@ class EventRouter {
73580
73647
  constructor(config) {
73581
73648
  // === State ===
73582
73649
  this.mode = 'idle';
73650
+ /** Mode to restore after a right-drag pan ends (so pan doesn't drop aiAssist). */
73651
+ this.modeBeforePan = 'idle';
73583
73652
  this.guiTool = 'pencil';
73584
73653
  this.state = {
73585
73654
  shiftHeld: false,
@@ -73705,7 +73774,20 @@ class EventRouter {
73705
73774
  * Set the GUI tool (from UI buttons).
73706
73775
  */
73707
73776
  setGuiTool(tool) {
73777
+ const prevTool = this.guiTool;
73708
73778
  this.guiTool = tool;
73779
+ // AI-assist owns the canvas: left-click/drag = prompt. Taking the
73780
+ // 'aiAssist' mode (not 'idle') is what auto-disables left-drag slice
73781
+ // scrubbing — `isDragSliceActive()` requires mode === 'idle'.
73782
+ if (tool === 'aiAssist') {
73783
+ this.state.crosshairEnabled = false;
73784
+ this.setMode('aiAssist');
73785
+ return;
73786
+ }
73787
+ // Leaving aiAssist → restore idle so slice-drag / wheel resume.
73788
+ if (prevTool === 'aiAssist' && this.mode === 'aiAssist') {
73789
+ this.setMode('idle');
73790
+ }
73709
73791
  // When entering any sphere-family tool, keep crosshair if active, otherwise idle
73710
73792
  if (SPHERE_TOOLS.has(tool)) {
73711
73793
  if (!this.state.crosshairEnabled) {
@@ -73719,8 +73801,9 @@ class EventRouter {
73719
73801
  * Blocked when draw or contrast mode is active, or left button is held (mutual exclusion).
73720
73802
  */
73721
73803
  toggleCrosshair() {
73722
- // Allow crosshair in drawing tools and all sphere-family tools (sphere / sphereBrush / sphereEraser)
73723
- if (!DRAWING_TOOLS.has(this.guiTool) && !SPHERE_TOOLS.has(this.guiTool))
73804
+ // Allow crosshair in drawing tools, all sphere-family tools, AND aiAssist
73805
+ // (so the crosshair can be used to debug while AI-assist is active).
73806
+ if (!DRAWING_TOOLS.has(this.guiTool) && !SPHERE_TOOLS.has(this.guiTool) && this.guiTool !== 'aiAssist')
73724
73807
  return;
73725
73808
  // Block crosshair activation during draw, contrast, or while left button held.
73726
73809
  // The leftButtonDown guard also enforces "once a sphere preview is on screen,
@@ -73728,7 +73811,15 @@ class EventRouter {
73728
73811
  if (this.state.shiftHeld || this.state.leftButtonDown || this.mode === 'draw' || this.mode === 'contrast')
73729
73812
  return;
73730
73813
  this.state.crosshairEnabled = !this.state.crosshairEnabled;
73731
- this.setMode(this.state.crosshairEnabled ? 'crosshair' : 'idle');
73814
+ // Crosshair has PRIORITY over aiAssist: turning it on takes the mode (so
73815
+ // AI point/box/scribble are suspended); turning it off restores aiAssist
73816
+ // (not idle) when the AI tool is the active tool, otherwise idle.
73817
+ if (this.state.crosshairEnabled) {
73818
+ this.setMode('crosshair');
73819
+ }
73820
+ else {
73821
+ this.setMode(this.guiTool === 'aiAssist' ? 'aiAssist' : 'idle');
73822
+ }
73732
73823
  }
73733
73824
  /**
73734
73825
  * Check if crosshair mode is enabled.
@@ -73864,8 +73955,8 @@ class EventRouter {
73864
73955
  }
73865
73956
  }
73866
73957
  if (this.contrastEnabled && this.keyboardSettings.contrast.includes(ev.key)) {
73867
- // Block contrast state when crosshair, draw, or any sphere-family tool is active (mutual exclusion)
73868
- if (!this.state.crosshairEnabled && this.mode !== 'draw'
73958
+ // Block contrast state when crosshair, draw, aiAssist, or any sphere-family tool is active (mutual exclusion)
73959
+ if (!this.state.crosshairEnabled && this.mode !== 'draw' && this.mode !== 'aiAssist'
73869
73960
  && !SPHERE_TOOLS.has(this.guiTool)) {
73870
73961
  this.state.ctrlHeld = true;
73871
73962
  }
@@ -73902,6 +73993,10 @@ class EventRouter {
73902
73993
  }
73903
73994
  else if (ev.button === 2) {
73904
73995
  this.state.rightButtonDown = true;
73996
+ // Remember the mode we're panning out of so we can restore it on
73997
+ // release — otherwise aiAssist (and any non-idle mode) is lost.
73998
+ if (this.mode !== 'pan')
73999
+ this.modeBeforePan = this.mode;
73905
74000
  this.setMode('pan');
73906
74001
  }
73907
74002
  // Route to external handler
@@ -73922,7 +74017,10 @@ class EventRouter {
73922
74017
  else if (ev.button === 2) {
73923
74018
  this.state.rightButtonDown = false;
73924
74019
  if (this.mode === 'pan') {
73925
- this.setMode('idle');
74020
+ // Restore aiAssist if we panned out of it; otherwise idle (unchanged
74021
+ // behaviour for normal drawing modes).
74022
+ this.setMode(this.modeBeforePan === 'aiAssist' ? 'aiAssist' : 'idle');
74023
+ this.modeBeforePan = 'idle';
73926
74024
  }
73927
74025
  }
73928
74026
  // Route to external handler
@@ -75845,6 +75943,474 @@ class SphereBrushTool extends BaseTool {
75845
75943
  }
75846
75944
  }
75847
75945
 
75946
+ /**
75947
+ * AiAssistTool — interactive prompt-based segmentation (experimental).
75948
+ *
75949
+ * The clinician left-clicks foreground / background points (or drags a box /
75950
+ * scribbles) on the current slice; a backend model returns a region mask that
75951
+ * is written into an INDEPENDENT scratch MaskVolume (`aiAssistMaskVolume`,
75952
+ * registered as `maskData.volumes['aiScratch']`) — never the validated layers.
75953
+ *
75954
+ * Network lives in the app layer: this tool only (a) maps screen → voxel-slice
75955
+ * coordinates, (b) accumulates the prompt for the current slice, (c) fires
75956
+ * `onPrompt` so the app can call the backend, and (d) applies the returned mask
75957
+ * via `applyMask`. The app's `useAiAssist` composable wires (c)→(d).
75958
+ *
75959
+ * Sandbox: snapshot on enter (`snapshot`), then on exit either `discard`
75960
+ * (restore snapshot) or merge is handled by NrrdTools (clone + pushGroup).
75961
+ *
75962
+ * Coordinate mapping supports all three views (axial/sagittal/coronal); it
75963
+ * inverts the per-axis display transform of RenderingUtils.renderSliceToCanvas
75964
+ * (only coronal 'y' is vertically flipped). Merge captures painted voxels from
75965
+ * any view (the scratch is a full 3D volume).
75966
+ */
75967
+ /** Scratch volume key in maskData.volumes — kept OUT of image.layers so
75968
+ * compositeAllLayers ignores it (we render it ourselves in start()). */
75969
+ const AI_SCRATCH_LAYER = "aiScratch";
75970
+ class AiAssistTool extends BaseTool {
75971
+ constructor(ctx, host) {
75972
+ super(ctx);
75973
+ /** Independent scratch volume (also registered at maskData.volumes['aiScratch']). */
75974
+ this.scratchVol = null;
75975
+ /** Snapshot of the scratch buffer taken on enter (for discard). */
75976
+ this.snapshotData = null;
75977
+ /** Frozen regions ("New region" commits here). Pixels recorded here survive a
75978
+ * later re-prediction — even on the SAME channel — so multiple separate regions
75979
+ * can be drawn without the newest prediction wiping the previous ones. Null =
75980
+ * nothing frozen yet (applySliceRle then behaves as a single live region). */
75981
+ this.committedVol = null;
75982
+ this.promptTool = "point";
75983
+ this.polarity = 1; // 1 = foreground (positive), 0 = background
75984
+ /** Target channel/label (1-8) the AI paints into — the "AI layer" channel. */
75985
+ this.channel = 1;
75986
+ /** Scribble brush radius (px), user-adjustable via slider. */
75987
+ this.scribbleSize = 5;
75988
+ /** Accumulated prompts for the CURRENT slice; reset on slice/axis change. */
75989
+ this.points = [];
75990
+ this.scribble = [];
75991
+ this.activeSlice = -1;
75992
+ // Drag state (box / scribble) — voxel coords for the prompt
75993
+ this.dragging = false;
75994
+ this.dragStart = null;
75995
+ // Screen-space (display px) copies for the LIVE preview drawn each frame.
75996
+ this.dragScreenStart = null;
75997
+ this.dragScreen = null;
75998
+ this.screenScribble = [];
75999
+ // Live cursor position (screen px) for the scribble brush-size preview ring —
76000
+ // updated on every hover move so the ring follows the mouse like the paint brush.
76001
+ this.hoverScreen = null;
76002
+ /** Fired when a prompt gesture completes — app calls backend, then applyMask(). */
76003
+ this.onPrompt = null;
76004
+ this.host = host;
76005
+ }
76006
+ // ── Configuration (driven from the panel via NrrdTools) ────────────────────
76007
+ setPromptTool(tool) {
76008
+ // Switching tool starts a clean gesture — drop any stale points/box/scribble
76009
+ // and drag state so the next click behaves predictably.
76010
+ if (tool !== this.promptTool) {
76011
+ this.resetPrompts();
76012
+ this.dragging = false;
76013
+ this.dragStart = null;
76014
+ }
76015
+ this.promptTool = tool;
76016
+ }
76017
+ setPolarity(label) { this.polarity = label === 0 ? 0 : 1; }
76018
+ setScribbleSize(size) {
76019
+ this.scribbleSize = Math.max(1, Math.min(40, Math.round(size)));
76020
+ }
76021
+ setChannel(channel) {
76022
+ const c = Math.max(1, Math.min(8, Math.round(channel)));
76023
+ // Changing channel starts a NEW region in the new colour — otherwise the
76024
+ // accumulated points would be re-predicted and repainted in the new label,
76025
+ // recolouring the previous channel's region.
76026
+ if (c !== this.channel)
76027
+ this.resetPrompts();
76028
+ this.channel = c;
76029
+ }
76030
+ getChannel() { return this.channel; }
76031
+ getScratchVolume() { return this.scratchVol; }
76032
+ /** Serialize the scratch volume as per-z-slice RLE (only NON-empty slices),
76033
+ * for persisting to ai_generated_nii_LPS on the backend. Binary (any channel →
76034
+ * 1); RLE is alternating run lengths starting with a 0-run — the exact format
76035
+ * the backend's rle_decode expects. Uses getSliceUint8 (the same path the
76036
+ * clinician layer's /api/mask/replace uses) so orientation matches. */
76037
+ getScratchSlices() {
76038
+ if (!this.scratchVol)
76039
+ return null;
76040
+ const { depth } = this.scratchVol.getDimensions();
76041
+ const out = [];
76042
+ let width = 0;
76043
+ let height = 0;
76044
+ for (let z = 0; z < depth; z++) {
76045
+ const { data, width: w, height: h } = this.scratchVol.getSliceUint8(z, "z");
76046
+ width = w;
76047
+ height = h;
76048
+ // RLE-encode (binarized) in one pass; skip fully-empty slices.
76049
+ const runs = [];
76050
+ let value = 0; // always begin with a 0-run (matches backend rle_encode)
76051
+ let count = 0;
76052
+ let any = false;
76053
+ for (let i = 0; i < data.length; i++) {
76054
+ const v = data[i] !== 0 ? 1 : 0;
76055
+ if (v)
76056
+ any = true;
76057
+ if (v === value) {
76058
+ count++;
76059
+ }
76060
+ else {
76061
+ runs.push(count);
76062
+ value = v;
76063
+ count = 1;
76064
+ }
76065
+ }
76066
+ runs.push(count);
76067
+ if (any)
76068
+ out.push({ sliceIndex: z, rle: runs });
76069
+ }
76070
+ return { axis: "z", width, height, slices: out };
76071
+ }
76072
+ // ── Sandbox lifecycle ──────────────────────────────────────────────────────
76073
+ /** Create (or reuse) the scratch volume sized to the layer grid and register
76074
+ * it under maskData.volumes['aiScratch']. Snapshots the empty buffer. */
76075
+ enter() {
76076
+ const volumes = this.ctx.protectedData.maskData.volumes;
76077
+ const baseId = this.ctx.nrrd_states.image.layers[0];
76078
+ const base = volumes[baseId];
76079
+ if (!base)
76080
+ return;
76081
+ const d = base.getDimensions();
76082
+ if (!this.scratchVol ||
76083
+ this.scratchVol.getDimensions().width !== d.width ||
76084
+ this.scratchVol.getDimensions().height !== d.height ||
76085
+ this.scratchVol.getDimensions().depth !== d.depth) {
76086
+ this.scratchVol = new MaskVolume(d.width, d.height, d.depth, 1);
76087
+ }
76088
+ else {
76089
+ this.scratchVol.clear();
76090
+ }
76091
+ volumes[AI_SCRATCH_LAYER] = this.scratchVol;
76092
+ // Paint the AI scratch with the AI-Assist palette (channel 1 = cyan) so the 2D
76093
+ // overlay matches the cyan ai_generated GLB. Scoped to THIS volume only — the
76094
+ // global palette / clinician mask colours are untouched.
76095
+ for (let ch = 1; ch <= 8; ch++) {
76096
+ const c = AI_MASK_CHANNEL_COLORS[ch];
76097
+ if (c)
76098
+ this.scratchVol.setChannelColor(ch, { r: c.r, g: c.g, b: c.b, a: c.a });
76099
+ }
76100
+ this.snapshotData = this.scratchVol.getRawData().slice();
76101
+ this.committedVol = null; // nothing frozen at session start
76102
+ this.resetPrompts();
76103
+ }
76104
+ /** Remove the scratch volume from the registry and drop references. */
76105
+ exit() {
76106
+ const volumes = this.ctx.protectedData.maskData.volumes;
76107
+ delete volumes[AI_SCRATCH_LAYER];
76108
+ this.scratchVol = null;
76109
+ this.snapshotData = null;
76110
+ this.committedVol = null;
76111
+ this.resetPrompts();
76112
+ this.dragging = false;
76113
+ this.dragStart = null;
76114
+ }
76115
+ /** Discard everything painted since enter() — restore the snapshot. */
76116
+ discard() {
76117
+ if (this.scratchVol && this.snapshotData) {
76118
+ this.scratchVol.setRawData(this.snapshotData.slice());
76119
+ }
76120
+ this.committedVol = null; // discard wipes frozen regions too
76121
+ this.resetPrompts();
76122
+ }
76123
+ /** "New region": freeze every voxel painted so far so it survives later
76124
+ * re-predictions (even on the same channel), then start a fresh prompt set.
76125
+ * This is what makes drawing multiple separate regions actually work — without
76126
+ * it the next prediction's same-channel cleanup erases the previous region. */
76127
+ commitRegion() {
76128
+ if (this.scratchVol) {
76129
+ const d = this.scratchVol.getDimensions();
76130
+ if (!this.committedVol ||
76131
+ this.committedVol.getDimensions().width !== d.width ||
76132
+ this.committedVol.getDimensions().height !== d.height ||
76133
+ this.committedVol.getDimensions().depth !== d.depth) {
76134
+ this.committedVol = new MaskVolume(d.width, d.height, d.depth, 1);
76135
+ }
76136
+ this.committedVol.setRawData(this.scratchVol.getRawData().slice());
76137
+ }
76138
+ this.resetPrompts();
76139
+ }
76140
+ /** Clear the in-progress prompt set (e.g. slice/axis change). Does NOT freeze —
76141
+ * use commitRegion() for the "New region" action. */
76142
+ resetPrompts() {
76143
+ this.points = [];
76144
+ this.scribble = [];
76145
+ this.box = undefined;
76146
+ this.dragScreenStart = null;
76147
+ this.dragScreen = null;
76148
+ this.screenScribble = [];
76149
+ }
76150
+ // ── Coordinate mapping (all three views) ───────────────────────────────────
76151
+ //
76152
+ // Mapping must INVERT exactly what RenderingUtils.renderSliceToCanvas does to
76153
+ // display the overlay (it is the renderer used by renderOverlay):
76154
+ // - axial (z) / sagittal (x): no flip
76155
+ // - coronal (y): vertical flip (scale(1,-1))
76156
+ // Slice voxel dims & canvas mm extents per axis (matches MaskVolume
76157
+ // getSliceDimensions + SphereTool.setSphereCanvasSize):
76158
+ // z → (W=width, H=height) over (x_mm, y_mm)
76159
+ // y → (W=width, H=depth ) over (x_mm, z_mm) [vertical flip]
76160
+ // x → (W=depth, H=height) over (z_mm, y_mm)
76161
+ /** Voxel-slice (width,height) for the current axis. */
76162
+ sliceDims() {
76163
+ if (!this.scratchVol)
76164
+ return null;
76165
+ const d = this.scratchVol.getDimensions();
76166
+ switch (this.ctx.protectedData.axis) {
76167
+ case "z": return { w: d.width, h: d.height };
76168
+ case "y": return { w: d.width, h: d.depth };
76169
+ case "x": return { w: d.depth, h: d.height };
76170
+ default: return null;
76171
+ }
76172
+ }
76173
+ /** Screen offset → voxel-slice (vx,vy) for the current axis. */
76174
+ toVoxel(e) {
76175
+ const sd = this.sliceDims();
76176
+ if (!sd || !this.scratchVol)
76177
+ return null;
76178
+ const nrrd = this.ctx.nrrd_states;
76179
+ const img = nrrd.image;
76180
+ const mx = e.offsetX / nrrd.view.sizeFactor;
76181
+ const my = e.offsetY / nrrd.view.sizeFactor;
76182
+ let hMM, vMM, flipV;
76183
+ switch (this.ctx.protectedData.axis) {
76184
+ case "z":
76185
+ hMM = img.nrrd_x_mm;
76186
+ vMM = img.nrrd_y_mm;
76187
+ flipV = false;
76188
+ break;
76189
+ case "y":
76190
+ hMM = img.nrrd_x_mm;
76191
+ vMM = img.nrrd_z_mm;
76192
+ flipV = true;
76193
+ break;
76194
+ case "x":
76195
+ hMM = img.nrrd_z_mm;
76196
+ vMM = img.nrrd_y_mm;
76197
+ flipV = false;
76198
+ break;
76199
+ default: return null;
76200
+ }
76201
+ const vx = Math.round(mx * sd.w / hMM);
76202
+ let vy = Math.round(my * sd.h / vMM);
76203
+ if (flipV)
76204
+ vy = sd.h - 1 - vy;
76205
+ if (vx < 0 || vx >= sd.w || vy < 0 || vy >= sd.h)
76206
+ return null;
76207
+ return { vx, vy, w: sd.w, h: sd.h };
76208
+ }
76209
+ // ── Pointer handlers (left button; right stays pan in EventRouter) ──────────
76210
+ onPointerDown(e) {
76211
+ const hit = this.toVoxel(e);
76212
+ if (!hit)
76213
+ return;
76214
+ this.syncSlice();
76215
+ if (this.promptTool === "point") {
76216
+ this.points.push({ x: hit.vx, y: hit.vy, label: this.polarity });
76217
+ this.emit();
76218
+ }
76219
+ else {
76220
+ // box / scribble: begin a drag
76221
+ this.dragging = true;
76222
+ this.dragStart = { x: hit.vx, y: hit.vy };
76223
+ this.dragScreenStart = { x: e.offsetX, y: e.offsetY };
76224
+ this.dragScreen = { x: e.offsetX, y: e.offsetY };
76225
+ this.screenScribble = [];
76226
+ if (this.promptTool === "scribble") {
76227
+ this.scribble.push({ x: hit.vx, y: hit.vy, label: this.polarity });
76228
+ this.screenScribble.push({ x: e.offsetX, y: e.offsetY });
76229
+ }
76230
+ }
76231
+ }
76232
+ onPointerMove(e) {
76233
+ // Track hover for the scribble preview ring even when not drawing.
76234
+ this.hoverScreen = { x: e.offsetX, y: e.offsetY };
76235
+ if (!this.dragging)
76236
+ return;
76237
+ // Track screen pos first so the live preview follows even slightly out of bounds.
76238
+ this.dragScreen = { x: e.offsetX, y: e.offsetY };
76239
+ const hit = this.toVoxel(e);
76240
+ if (!hit)
76241
+ return;
76242
+ if (this.promptTool === "scribble") {
76243
+ this.scribble.push({ x: hit.vx, y: hit.vy, label: this.polarity });
76244
+ this.screenScribble.push({ x: e.offsetX, y: e.offsetY });
76245
+ }
76246
+ else if (this.promptTool === "box" && this.dragStart) {
76247
+ this.box = {
76248
+ x0: this.dragStart.x, y0: this.dragStart.y,
76249
+ x1: hit.vx, y1: hit.vy,
76250
+ label: this.polarity, // foreground box grows within; background box excludes
76251
+ };
76252
+ }
76253
+ }
76254
+ onPointerUp(_e) {
76255
+ if (!this.dragging)
76256
+ return;
76257
+ this.dragging = false;
76258
+ this.emit();
76259
+ this.dragStart = null;
76260
+ this.dragScreenStart = null;
76261
+ this.dragScreen = null;
76262
+ this.screenScribble = [];
76263
+ }
76264
+ /** Reset the accumulated prompt set when the user scrubs to a new slice. */
76265
+ syncSlice() {
76266
+ const slice = this.ctx.nrrd_states.view.currentSliceIndex;
76267
+ if (slice !== this.activeSlice) {
76268
+ this.activeSlice = slice;
76269
+ this.resetPrompts();
76270
+ }
76271
+ }
76272
+ emit() {
76273
+ if (!this.onPrompt)
76274
+ return;
76275
+ const sd = this.sliceDims();
76276
+ if (!sd)
76277
+ return;
76278
+ this.onPrompt({
76279
+ axis: this.ctx.protectedData.axis,
76280
+ sliceIndex: this.ctx.nrrd_states.view.currentSliceIndex,
76281
+ width: sd.w,
76282
+ height: sd.h,
76283
+ points: [...this.points],
76284
+ box: this.box,
76285
+ scribble: this.scribble.length ? [...this.scribble] : undefined,
76286
+ scribbleRadius: this.scribbleSize,
76287
+ });
76288
+ }
76289
+ // ── Apply backend result → scratch volume ──────────────────────────────────
76290
+ applyMask(result) {
76291
+ if (!this.scratchVol)
76292
+ return;
76293
+ // 3D result (engine B): write each slice across the covered span.
76294
+ if (result.slices && result.sliceRange) {
76295
+ const [lo] = result.sliceRange;
76296
+ for (let k = 0; k < result.slices.length; k++) {
76297
+ this.applySliceRle(result.slices[k], result.axis, lo + k, result.width, result.height);
76298
+ }
76299
+ return;
76300
+ }
76301
+ // 2D result (engines mock / regiongrow / medsam2d): single slice.
76302
+ this.applySliceRle(result.rle, result.axis, result.sliceIndex, result.width, result.height);
76303
+ }
76304
+ /** Merge one RLE-encoded predicted slice into the scratch volume at sliceIndex.
76305
+ * Predicted pixels → current channel; stale current-channel pixels cleared
76306
+ * UNLESS they belong to a frozen (committed) region; OTHER channels preserved. */
76307
+ applySliceRle(rle, axis, sliceIndex, width, height) {
76308
+ if (!this.scratchVol)
76309
+ return;
76310
+ const predicted = new Uint8Array(width * height);
76311
+ let pos = 0;
76312
+ let value = 0;
76313
+ for (const run of rle) {
76314
+ if (value === 1 && run > 0) {
76315
+ const end = Math.min(pos + run, predicted.length);
76316
+ for (let i = pos; i < end; i++)
76317
+ predicted[i] = 1;
76318
+ }
76319
+ pos += run;
76320
+ value ^= 1;
76321
+ }
76322
+ const ch = this.channel;
76323
+ try {
76324
+ const out = this.scratchVol.getSliceUint8(sliceIndex, axis).data; // copy
76325
+ // Frozen same-channel pixels for this slice (if any region was committed).
76326
+ let frozen = null;
76327
+ if (this.committedVol) {
76328
+ try {
76329
+ frozen = this.committedVol.getSliceUint8(sliceIndex, axis).data;
76330
+ }
76331
+ catch (_a) {
76332
+ frozen = null;
76333
+ }
76334
+ }
76335
+ const n = Math.min(out.length, predicted.length);
76336
+ for (let i = 0; i < n; i++) {
76337
+ if (predicted[i])
76338
+ out[i] = ch;
76339
+ // Clear stale LIVE same-channel pixels (refinement), but never erase a
76340
+ // frozen region committed via "New region".
76341
+ else if (out[i] === ch && !(frozen && frozen[i] === ch))
76342
+ out[i] = 0;
76343
+ }
76344
+ this.scratchVol.setSliceUint8(sliceIndex, out, axis);
76345
+ }
76346
+ catch (_b) {
76347
+ // slice out of bounds / dims changed — ignore
76348
+ }
76349
+ }
76350
+ // ── Overlay render (called from DrawToolCore.start() while in aiAssist) ──────
76351
+ renderOverlay(targetCtx) {
76352
+ if (!this.scratchVol)
76353
+ return;
76354
+ const axis = this.ctx.protectedData.axis;
76355
+ const slice = this.ctx.nrrd_states.view.currentSliceIndex;
76356
+ const buffer = this.host.getOrCreateSliceBuffer(axis);
76357
+ if (!buffer)
76358
+ return;
76359
+ try {
76360
+ this.host.renderSliceToCanvas(AI_SCRATCH_LAYER, axis, slice, buffer, targetCtx, this.ctx.nrrd_states.view.changedWidth, this.ctx.nrrd_states.view.changedHeight);
76361
+ }
76362
+ catch (_a) {
76363
+ // volume not ready / slice out of bounds
76364
+ }
76365
+ // Live drag preview (screen space) — rubber-band box / scribble stroke so the
76366
+ // user sees the gesture before the prediction lands on pointer-up.
76367
+ if (this.dragging && this.dragScreenStart && this.dragScreen) {
76368
+ targetCtx.save();
76369
+ // Green-ish for foreground (include), red-ish for background (exclude).
76370
+ const color = this.polarity === 1 ? "rgba(120,255,180,0.95)" : "rgba(255,120,120,0.95)";
76371
+ targetCtx.strokeStyle = color;
76372
+ if (this.promptTool === "box") {
76373
+ targetCtx.setLineDash([5, 4]);
76374
+ targetCtx.lineWidth = 1.5;
76375
+ const x = Math.min(this.dragScreenStart.x, this.dragScreen.x);
76376
+ const y = Math.min(this.dragScreenStart.y, this.dragScreen.y);
76377
+ const w = Math.abs(this.dragScreen.x - this.dragScreenStart.x);
76378
+ const h = Math.abs(this.dragScreen.y - this.dragScreenStart.y);
76379
+ targetCtx.strokeRect(x, y, w, h);
76380
+ }
76381
+ else if (this.promptTool === "scribble" && this.screenScribble.length > 1) {
76382
+ targetCtx.lineCap = "round";
76383
+ targetCtx.lineJoin = "round";
76384
+ // Stroke thickness reflects the scribble brush size (diameter ≈ 2·radius).
76385
+ targetCtx.lineWidth = Math.max(2, this.scribbleSize * 2);
76386
+ targetCtx.beginPath();
76387
+ targetCtx.moveTo(this.screenScribble[0].x, this.screenScribble[0].y);
76388
+ for (let i = 1; i < this.screenScribble.length; i++) {
76389
+ targetCtx.lineTo(this.screenScribble[i].x, this.screenScribble[i].y);
76390
+ }
76391
+ targetCtx.stroke();
76392
+ }
76393
+ targetCtx.restore();
76394
+ }
76395
+ // Scribble brush-size preview ring (when NOT dragging) — a circle centred on
76396
+ // the cursor whose radius = scribbleSize, so the user sees the brush size and
76397
+ // how the slider changes it (mirrors the paint brush's preview ring). Drawn
76398
+ // every frame from the live hover position by copper3d's render loop.
76399
+ if (this.promptTool === "scribble" && !this.dragging && this.hoverScreen) {
76400
+ targetCtx.save();
76401
+ const fg = this.polarity === 1;
76402
+ targetCtx.strokeStyle = fg ? "rgba(120,255,180,0.9)" : "rgba(255,120,120,0.9)";
76403
+ targetCtx.lineWidth = 1.5;
76404
+ if (!fg)
76405
+ targetCtx.setLineDash([4, 4]); // dashed for background (exclude), like the eraser
76406
+ targetCtx.beginPath();
76407
+ targetCtx.arc(this.hoverScreen.x, this.hoverScreen.y, Math.max(2, this.scribbleSize), 0, Math.PI * 2);
76408
+ targetCtx.stroke();
76409
+ targetCtx.restore();
76410
+ }
76411
+ }
76412
+ }
76413
+
75848
76414
  /**
75849
76415
  * DrawToolCore — Tool orchestration and event routing.
75850
76416
  *
@@ -75965,6 +76531,10 @@ class DrawToolCore {
75965
76531
  setEmptyCanvasSize: (axis) => this.setEmptyCanvasSize(axis),
75966
76532
  reloadMasksFromVolume: () => this.reloadMasksFromVolume(),
75967
76533
  });
76534
+ this.aiAssistTool = new AiAssistTool(toolCtx, {
76535
+ renderSliceToCanvas: (layer, axis, sliceIndex, buffer, ctx, w, h) => this.renderer.renderSliceToCanvas(layer, axis, sliceIndex, buffer, ctx, w, h),
76536
+ getOrCreateSliceBuffer: (axis) => this.renderer.getOrCreateSliceBuffer(axis),
76537
+ });
75968
76538
  }
75969
76539
  initDrawToolCore() {
75970
76540
  // Initialize EventRouter for centralized event handling
@@ -76071,6 +76641,8 @@ class DrawToolCore {
76071
76641
  // Block contrast toggle during crosshair, draw, or sphere (mutual exclusion)
76072
76642
  if (this.eventRouter.isCrosshairEnabled() || this.eventRouter.getMode() === 'draw')
76073
76643
  return;
76644
+ if (this.eventRouter.getMode() === 'aiAssist')
76645
+ return;
76074
76646
  if (this.state.gui_states.mode.sphere)
76075
76647
  return;
76076
76648
  // Toggle contrast mode manually since it's on keyup
@@ -76084,6 +76656,10 @@ class DrawToolCore {
76084
76656
  });
76085
76657
  // Register pointer handlers with EventRouter
76086
76658
  this.eventRouter.setPointerMoveHandler((e) => {
76659
+ if (this.eventRouter.getMode() === 'aiAssist') {
76660
+ this.aiAssistTool.onPointerMove(e);
76661
+ return;
76662
+ }
76087
76663
  if (this.drawingTool.isActive || this.panTool.isActive) {
76088
76664
  this.drawingPrameters.handleOnDrawingMouseMove(e);
76089
76665
  }
@@ -76098,6 +76674,14 @@ class DrawToolCore {
76098
76674
  }
76099
76675
  });
76100
76676
  this.eventRouter.setPointerUpHandler((e) => {
76677
+ if (this.eventRouter.getMode() === 'aiAssist') {
76678
+ if (e.button === 0)
76679
+ this.aiAssistTool.onPointerUp(e);
76680
+ // Right-button pan must still clean up (cursor/state) even in AI mode.
76681
+ else if (e.button === 2)
76682
+ this.panTool.onPointerUp(e, this.state.gui_states.viewConfig.defaultPaintCursor);
76683
+ return;
76684
+ }
76101
76685
  // Restore Scroll:Zoom if we swapped to Scroll:Slice on drag-start.
76102
76686
  // Must run outside the drawing-tool gate below: slice-drag doesn't
76103
76687
  // flip any of those flags, so the gate would skip restoration.
@@ -76257,7 +76841,10 @@ class DrawToolCore {
76257
76841
  this.activeWheelMode = 'none';
76258
76842
  }
76259
76843
  if (e.button === 0) {
76260
- if (this.eventRouter.getMode() === 'draw') {
76844
+ if (this.eventRouter.getMode() === 'aiAssist') {
76845
+ this.aiAssistTool.onPointerDown(e);
76846
+ }
76847
+ else if (this.eventRouter.getMode() === 'draw') {
76261
76848
  this.drawingTool.onPointerDown(e);
76262
76849
  }
76263
76850
  else if (this.eventRouter.isCrosshairEnabled()) {
@@ -76372,6 +76959,12 @@ class DrawToolCore {
76372
76959
  || this.state.gui_states.mode.sphereEraser) {
76373
76960
  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);
76374
76961
  }
76962
+ // AI-assist scratch overlay — drawn each frame whenever an AI session is
76963
+ // active (scratch volume exists), so it stays visible even when crosshair
76964
+ // temporarily takes over the interaction mode for debugging.
76965
+ if (this.aiAssistTool.getScratchVolume()) {
76966
+ this.aiAssistTool.renderOverlay(this.state.protectedData.ctxes.drawingCtx);
76967
+ }
76375
76968
  }
76376
76969
  else {
76377
76970
  this.redrawDisplayCanvas();
@@ -77976,6 +78569,13 @@ class NrrdTools {
77976
78569
  this._calculatorActive = false;
77977
78570
  /** Stored closure callbacks from gui.ts setupGui() */
77978
78571
  this.guiCallbacks = null;
78572
+ // ═══════════════════════════════════════════════════════════════════════════
78573
+ // 10b. AI Assist (experimental) — interactive prompt segmentation
78574
+ // ═══════════════════════════════════════════════════════════════════════════
78575
+ /** Whether AI-assist mode is currently active. */
78576
+ this._aiAssistActive = false;
78577
+ /** Layer visibility snapshot taken on enter, restored on exit. */
78578
+ this._aiPrevVisibility = null;
77979
78579
  this.container = container;
77980
78580
  // Create shared state
77981
78581
  const mainAreaContainer = document.createElement("div");
@@ -78143,6 +78743,13 @@ class NrrdTools {
78143
78743
  var _a, _b, _c;
78144
78744
  if (!this.guiCallbacks)
78145
78745
  return;
78746
+ // While AI-assist owns the canvas, BLOCK switching to other tools — the user
78747
+ // must explicitly Exit AI Assist from its panel first. (The Operation panel
78748
+ // also disables its tool buttons via the AiAssist:ActiveChanged event, so this
78749
+ // is just a defensive guard.)
78750
+ if (this._aiAssistActive && mode !== "aiAssist") {
78751
+ return;
78752
+ }
78146
78753
  const prevSphere = this.state.gui_states.mode.sphere;
78147
78754
  const prevSphereBrush = this.state.gui_states.mode.sphereBrush;
78148
78755
  const prevSphereEraser = this.state.gui_states.mode.sphereEraser;
@@ -78791,6 +79398,111 @@ class NrrdTools {
78791
79398
  this.resetLayerCanvas();
78792
79399
  this.reloadMasksFromVolume();
78793
79400
  }
79401
+ isAiAssistActive() { return this._aiAssistActive; }
79402
+ /**
79403
+ * Enter AI-assist mode (sandbox): hides the existing layer masks so ONLY the
79404
+ * AI overlay is shown, takes canvas ownership (left-click = prompt), and creates
79405
+ * the scratch volume. Right-drag still pans; wheel/slider still scrub slices.
79406
+ * The hidden masks are restored on exit (merge writes into them first if asked).
79407
+ */
79408
+ enterAiAssistMode() {
79409
+ var _a;
79410
+ if (this._aiAssistActive)
79411
+ return;
79412
+ this._aiAssistActive = true;
79413
+ this.dragOperator.removeDragMode();
79414
+ // Hide all layer masks (visibility=false survives recomposite on slice change).
79415
+ const vis = this.state.gui_states.layerChannel.layerVisibility;
79416
+ this._aiPrevVisibility = Object.assign({}, vis);
79417
+ for (const layerId of this.state.nrrd_states.image.layers)
79418
+ vis[layerId] = false;
79419
+ this.drawCore.renderer.compositeAllLayers();
79420
+ this.drawCore.aiAssistTool.enter();
79421
+ (_a = this.drawCore.eventRouter) === null || _a === void 0 ? void 0 : _a.setGuiTool("aiAssist");
79422
+ }
79423
+ /**
79424
+ * Exit AI-assist mode: drop the scratch volume, restore normal tooling AND the
79425
+ * layer-mask visibility hidden on enter. If the caller merged first, the merged
79426
+ * result is now part of the layer volume and reappears with the restored masks.
79427
+ */
79428
+ exitAiAssistMode() {
79429
+ var _a;
79430
+ if (!this._aiAssistActive)
79431
+ return;
79432
+ this._aiAssistActive = false;
79433
+ this.drawCore.aiAssistTool.exit();
79434
+ this.dragOperator.configDragMode();
79435
+ (_a = this.drawCore.eventRouter) === null || _a === void 0 ? void 0 : _a.setGuiTool("pencil");
79436
+ if (this._aiPrevVisibility) {
79437
+ const vis = this.state.gui_states.layerChannel.layerVisibility;
79438
+ for (const k of Object.keys(this._aiPrevVisibility))
79439
+ vis[k] = this._aiPrevVisibility[k];
79440
+ this._aiPrevVisibility = null;
79441
+ }
79442
+ this.reloadMasksFromVolume();
79443
+ }
79444
+ // — Driver methods called by the app-layer composable —
79445
+ aiSetPromptTool(tool) { this.drawCore.aiAssistTool.setPromptTool(tool); }
79446
+ aiSetPolarity(label) { this.drawCore.aiAssistTool.setPolarity(label); }
79447
+ /** Set the AI-layer channel (1-8) the predictions paint into. */
79448
+ aiSetChannel(channel) { this.drawCore.aiAssistTool.setChannel(channel); }
79449
+ /** Set the scribble brush radius (px). */
79450
+ aiSetScribbleSize(size) { this.drawCore.aiAssistTool.setScribbleSize(size); }
79451
+ /** Register the callback invoked when a prompt gesture completes (app → backend). */
79452
+ aiOnPrompt(cb) { this.drawCore.aiAssistTool.onPrompt = cb; }
79453
+ /** Apply a backend mask result into the scratch volume (overlay repaints next frame). */
79454
+ aiApplyMask(result) { this.drawCore.aiAssistTool.applyMask(result); }
79455
+ /** Clear the in-progress prompt set (e.g. slice change). */
79456
+ aiClearPrompts() { this.drawCore.aiAssistTool.resetPrompts(); }
79457
+ /** "New region": freeze current regions (they persist) + start a fresh prompt set. */
79458
+ aiCommitRegion() { this.drawCore.aiAssistTool.commitRegion(); }
79459
+ /** Discard all AI scratch painting since enter (sandbox discard). */
79460
+ aiDiscard() { this.drawCore.aiAssistTool.discard(); }
79461
+ /** True if the scratch volume holds any predicted voxels. */
79462
+ 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; }
79463
+ /** Serialize the AI scratch as per-slice RLE for persisting to ai_generated_nii_LPS. */
79464
+ aiGetScratchSlices() {
79465
+ return this.drawCore.aiAssistTool.getScratchSlices();
79466
+ }
79467
+ /**
79468
+ * Merge the AI scratch into a target layer as a single undoable group (sandbox
79469
+ * merge — best-practice: non-destructive + one Ctrl+Z). The scratch's channel
79470
+ * labels are PRESERVED (each AI channel maps onto the same channel of the
79471
+ * target layer). Scans all z-slices, so voxels painted from any view are caught.
79472
+ */
79473
+ aiCommitToLayer(targetLayer = "layer1") {
79474
+ const scratch = this.drawCore.aiAssistTool.getScratchVolume();
79475
+ if (!scratch)
79476
+ return;
79477
+ const target = this.state.protectedData.maskData.volumes[targetLayer];
79478
+ if (!target)
79479
+ return;
79480
+ const dims = scratch.getDimensions();
79481
+ const deltas = [];
79482
+ for (let z = 0; z < dims.depth; z++) {
79483
+ const sc = scratch.getSliceUint8(z, "z").data;
79484
+ let any = false;
79485
+ for (let i = 0; i < sc.length; i++) {
79486
+ if (sc[i] !== 0) {
79487
+ any = true;
79488
+ break;
79489
+ }
79490
+ }
79491
+ if (!any)
79492
+ continue;
79493
+ const oldSlice = target.getSliceUint8(z, "z").data; // copy
79494
+ const newSlice = oldSlice.slice();
79495
+ for (let i = 0; i < newSlice.length; i++) {
79496
+ if (sc[i] !== 0)
79497
+ newSlice[i] = sc[i]; // preserve the AI channel label
79498
+ }
79499
+ target.setSliceUint8(z, newSlice, "z");
79500
+ deltas.push({ layerId: targetLayer, axis: "z", sliceIndex: z, oldSlice, newSlice: newSlice.slice() });
79501
+ }
79502
+ if (deltas.length)
79503
+ this.drawCore.undoManager.pushGroup(deltas);
79504
+ this.reloadMasksFromVolume();
79505
+ }
78794
79506
  // ═══════════════════════════════════════════════════════════════════════════
78795
79507
  // 11. Clear / Reset
78796
79508
  // ═══════════════════════════════════════════════════════════════════════════
@@ -79154,7 +79866,7 @@ function evaluateElement(element, weights) {
79154
79866
  }
79155
79867
 
79156
79868
  // import * as kiwrious from "copper3d_plugin_heart_k";
79157
- const REVISION = "v3.4.9-beta";
79869
+ const REVISION = "v3.5.0-beta";
79158
79870
  console.log(`%cCopper3D Visualisation %cBeta:${REVISION}`, "padding: 3px;color:white; background:#023047", "padding: 3px;color:white; background:#f50a25");
79159
79871
 
79160
- export { CHANNEL_COLORS, CHANNEL_HEX_COLORS, CameraViewPoint, Copper3dTrackballControls, GaussianSmoother, MeshNodeTool, NrrdTools, REVISION, addBoxHelper, addLabelToScene, configKiwriousHeart, convert3DPostoScreenPos, convertScreenPosto3DPos, copperMScene, copperMSceneRenderer, copperRenderer, copperRendererOnDemond, copperScene, copperSceneOnDemond, createTexture2D_NRRD, fullScreenListenner, kiwrious, loading, removeGuiFolderChilden, rgbaToCss, rgbaToHex, setHDRFilePath, throttle };
79872
+ export { AI_CHANNEL_HEX_COLORS, AI_MASK_CHANNEL_COLORS, CHANNEL_COLORS, CHANNEL_HEX_COLORS, CameraViewPoint, Copper3dTrackballControls, GaussianSmoother, MeshNodeTool, NrrdTools, REVISION, addBoxHelper, addLabelToScene, configKiwriousHeart, convert3DPostoScreenPos, convertScreenPosto3DPos, copperMScene, copperMSceneRenderer, copperRenderer, copperRendererOnDemond, copperScene, copperSceneOnDemond, createTexture2D_NRRD, fullScreenListenner, kiwrious, loading, removeGuiFolderChilden, rgbaToCss, rgbaToHex, setHDRFilePath, throttle };