copper3d 3.4.8 → 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 (57) hide show
  1. package/dist/Loader/copperDicomLoader.d.ts +7 -1
  2. package/dist/Loader/copperDicomLoader.js +45 -0
  3. package/dist/Loader/copperDicomLoader.js.map +1 -1
  4. package/dist/Renderer/baseRenderer.d.ts +3 -0
  5. package/dist/Renderer/baseRenderer.js +20 -0
  6. package/dist/Renderer/baseRenderer.js.map +1 -1
  7. package/dist/Renderer/copperMSceneRenderer.d.ts +3 -0
  8. package/dist/Renderer/copperMSceneRenderer.js +22 -0
  9. package/dist/Renderer/copperMSceneRenderer.js.map +1 -1
  10. package/dist/Renderer/copperRenderer.js +2 -0
  11. package/dist/Renderer/copperRenderer.js.map +1 -1
  12. package/dist/Scene/copperScene.d.ts +8 -3
  13. package/dist/Scene/copperScene.js +173 -34
  14. package/dist/Scene/copperScene.js.map +1 -1
  15. package/dist/Utils/segmentation/DrawToolCore.d.ts +3 -0
  16. package/dist/Utils/segmentation/DrawToolCore.js +29 -1
  17. package/dist/Utils/segmentation/DrawToolCore.js.map +1 -1
  18. package/dist/Utils/segmentation/NrrdTools.d.ts +54 -0
  19. package/dist/Utils/segmentation/NrrdTools.js +119 -0
  20. package/dist/Utils/segmentation/NrrdTools.js.map +1 -1
  21. package/dist/Utils/segmentation/core/index.d.ts +1 -1
  22. package/dist/Utils/segmentation/core/index.js +1 -1
  23. package/dist/Utils/segmentation/core/index.js.map +1 -1
  24. package/dist/Utils/segmentation/core/types.d.ts +11 -1
  25. package/dist/Utils/segmentation/core/types.js +30 -0
  26. package/dist/Utils/segmentation/core/types.js.map +1 -1
  27. package/dist/Utils/segmentation/eventRouter/EventRouter.d.ts +2 -0
  28. package/dist/Utils/segmentation/eventRouter/EventRouter.js +37 -6
  29. package/dist/Utils/segmentation/eventRouter/EventRouter.js.map +1 -1
  30. package/dist/Utils/segmentation/eventRouter/types.d.ts +3 -2
  31. package/dist/Utils/segmentation/tools/AiAssistTool.d.ts +146 -0
  32. package/dist/Utils/segmentation/tools/AiAssistTool.js +470 -0
  33. package/dist/Utils/segmentation/tools/AiAssistTool.js.map +1 -0
  34. package/dist/Utils/texture2d.d.ts +8 -6
  35. package/dist/Utils/texture2d.js +30 -37
  36. package/dist/Utils/texture2d.js.map +1 -1
  37. package/dist/bundle.esm.js +975 -557
  38. package/dist/bundle.umd.js +976 -556
  39. package/dist/index.d.ts +6 -5
  40. package/dist/index.js +3 -3
  41. package/dist/index.js.map +1 -1
  42. package/dist/types/Loader/copperDicomLoader.d.ts +7 -1
  43. package/dist/types/Renderer/baseRenderer.d.ts +3 -0
  44. package/dist/types/Renderer/copperMSceneRenderer.d.ts +3 -0
  45. package/dist/types/Scene/copperScene.d.ts +8 -3
  46. package/dist/types/Utils/segmentation/DrawToolCore.d.ts +3 -0
  47. package/dist/types/Utils/segmentation/NrrdTools.d.ts +54 -0
  48. package/dist/types/Utils/segmentation/core/index.d.ts +1 -1
  49. package/dist/types/Utils/segmentation/core/types.d.ts +11 -1
  50. package/dist/types/Utils/segmentation/eventRouter/EventRouter.d.ts +2 -0
  51. package/dist/types/Utils/segmentation/eventRouter/types.d.ts +3 -2
  52. package/dist/types/Utils/segmentation/tools/AiAssistTool.d.ts +146 -0
  53. package/dist/types/Utils/texture2d.d.ts +8 -6
  54. package/dist/types/index.d.ts +6 -5
  55. package/dist/types/types/types.d.ts +43 -1
  56. package/dist/types/types.d.ts +43 -1
  57. package/package.json +1 -1
@@ -40834,6 +40834,17 @@ void main() {
40834
40834
  windowCenter = 226;
40835
40835
  windowWidth = 537;
40836
40836
  }
40837
+ // Spatial geometry tags (DS VR, backslash-delimited) used to place the image plane
40838
+ // at its true world pose, plus InstanceNumber as an ordering fallback.
40839
+ const ipp = parseDS(dataSet.string("x00200032")); // ImagePositionPatient
40840
+ const iop = parseDS(dataSet.string("x00200037")); // ImageOrientationPatient
40841
+ const spacing = parseDS(dataSet.string("x00280030")); // PixelSpacing
40842
+ let instanceNumber = parseInt(dataSet.string("x00200013"));
40843
+ if (Number.isNaN(instanceNumber))
40844
+ instanceNumber = undefined;
40845
+ const corners = ipp && iop && spacing
40846
+ ? computeImagePlaneCorners(ipp, iop, spacing, w, h)
40847
+ : undefined;
40837
40848
  let pixelData = dataSet.elements.x7fe00010;
40838
40849
  let uint16 = convertImplicitElement(pixelData, dicomFileAsBuffer);
40839
40850
  let voiLUT;
@@ -40852,10 +40863,44 @@ void main() {
40852
40863
  uint16,
40853
40864
  uint8,
40854
40865
  order,
40866
+ instanceNumber,
40867
+ imagePositionPatient: ipp,
40868
+ imageOrientationPatient: iop,
40869
+ pixelSpacing: spacing,
40870
+ corners,
40855
40871
  };
40856
40872
  callback && callback(copperVolume);
40857
40873
  });
40858
40874
  }
40875
+ // Parse a DICOM DS/IS multi-value string ("a\\b\\c") into a number[].
40876
+ function parseDS(s) {
40877
+ if (!s)
40878
+ return undefined;
40879
+ const a = s.split("\\").map((v) => parseFloat(v));
40880
+ return a.length && !a.some((n) => Number.isNaN(n)) ? a : undefined;
40881
+ }
40882
+ /**
40883
+ * Compute the 4 world-space corners of a DICOM image plane from its geometry tags.
40884
+ * IPP is the CENTER of pixel (0,0), so corners sit half a pixel out. PixelSpacing is
40885
+ * [rowSpacing, colSpacing]; IOP's first vector is the +column-index direction.
40886
+ */
40887
+ function computeImagePlaneCorners(ipp, iop, spacing, cols, rows) {
40888
+ const row = new Vector3(iop[0], iop[1], iop[2]); // +column-index dir
40889
+ const col = new Vector3(iop[3], iop[4], iop[5]); // +row-index dir
40890
+ const sc = spacing[1]; // column spacing (along row)
40891
+ const sr = spacing[0]; // row spacing (along col)
40892
+ const O = new Vector3(ipp[0], ipp[1], ipp[2]); // center of pixel (0,0)
40893
+ const p = (a, b) => O.clone()
40894
+ .addScaledVector(row, a * sc)
40895
+ .addScaledVector(col, b * sr)
40896
+ .toArray();
40897
+ return {
40898
+ tl: p(-0.5, -0.5),
40899
+ tr: p(cols - 0.5, -0.5),
40900
+ bl: p(-0.5, rows - 0.5),
40901
+ br: p(cols - 0.5, rows - 0.5),
40902
+ };
40903
+ }
40859
40904
  function convertImplicitElement(elementData, dicomFileAsBuffer) {
40860
40905
  let w1Buffer = dicomParser.sharedCopy(dicomFileAsBuffer, elementData.dataOffset, elementData.length);
40861
40906
  let unit16 = new Uint16Array(w1Buffer.buffer, w1Buffer.byteOffset, w1Buffer.byteLength / Uint16Array.BYTES_PER_ELEMENT);
@@ -40892,514 +40937,23 @@ void main() {
40892
40937
  };
40893
40938
  }
40894
40939
 
40895
- // DEFLATE is a complex format; to read this code, you should probably check the RFC first:
40940
+ var vert_2d = "#define GLSLIFY 1\nout vec2 vUv;void main(){gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);vUv=vec2(uv.x,1.0-uv.y);}"; // eslint-disable-line
40896
40941
 
40897
- // aliases for shorter compressed code (most minifers don't do this)
40898
- var u8$1 = Uint8Array, u16$1 = Uint16Array, u32 = Uint32Array;
40899
- // fixed length extra bits
40900
- var fleb$1 = new u8$1([0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0, /* unused */ 0, 0, /* impossible */ 0]);
40901
- // fixed distance extra bits
40902
- // see fleb note
40903
- var fdeb$1 = new u8$1([0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13, /* unused */ 0, 0]);
40904
- // code length index map
40905
- var clim$1 = new u8$1([16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]);
40906
- // get base, reverse index map from extra bits
40907
- var freb$1 = function (eb, start) {
40908
- var b = new u16$1(31);
40909
- for (var i = 0; i < 31; ++i) {
40910
- b[i] = start += 1 << eb[i - 1];
40911
- }
40912
- // numbers here are at max 18 bits
40913
- var r = new u32(b[30]);
40914
- for (var i = 1; i < 30; ++i) {
40915
- for (var j = b[i]; j < b[i + 1]; ++j) {
40916
- r[j] = ((j - b[i]) << 5) | i;
40917
- }
40918
- }
40919
- return [b, r];
40920
- };
40921
- var _a$1 = freb$1(fleb$1, 2), fl$1 = _a$1[0], revfl$1 = _a$1[1];
40922
- // we can ignore the fact that the other numbers are wrong; they never happen anyway
40923
- fl$1[28] = 258, revfl$1[258] = 28;
40924
- var _b$1 = freb$1(fdeb$1, 0), fd$1 = _b$1[0];
40925
- // map of value to reverse (assuming 16 bits)
40926
- var rev$1 = new u16$1(32768);
40927
- for (var i$1 = 0; i$1 < 32768; ++i$1) {
40928
- // reverse table algorithm from SO
40929
- var x$1 = ((i$1 & 0xAAAA) >>> 1) | ((i$1 & 0x5555) << 1);
40930
- x$1 = ((x$1 & 0xCCCC) >>> 2) | ((x$1 & 0x3333) << 2);
40931
- x$1 = ((x$1 & 0xF0F0) >>> 4) | ((x$1 & 0x0F0F) << 4);
40932
- rev$1[i$1] = (((x$1 & 0xFF00) >>> 8) | ((x$1 & 0x00FF) << 8)) >>> 1;
40933
- }
40934
- // create huffman tree from u8 "map": index -> code length for code index
40935
- // mb (max bits) must be at most 15
40936
- // TODO: optimize/split up?
40937
- var hMap$1 = (function (cd, mb, r) {
40938
- var s = cd.length;
40939
- // index
40940
- var i = 0;
40941
- // u16 "map": index -> # of codes with bit length = index
40942
- var l = new u16$1(mb);
40943
- // length of cd must be 288 (total # of codes)
40944
- for (; i < s; ++i) {
40945
- if (cd[i])
40946
- ++l[cd[i] - 1];
40947
- }
40948
- // u16 "map": index -> minimum code for bit length = index
40949
- var le = new u16$1(mb);
40950
- for (i = 0; i < mb; ++i) {
40951
- le[i] = (le[i - 1] + l[i - 1]) << 1;
40952
- }
40953
- var co;
40954
- if (r) {
40955
- // u16 "map": index -> number of actual bits, symbol for code
40956
- co = new u16$1(1 << mb);
40957
- // bits to remove for reverser
40958
- var rvb = 15 - mb;
40959
- for (i = 0; i < s; ++i) {
40960
- // ignore 0 lengths
40961
- if (cd[i]) {
40962
- // num encoding both symbol and bits read
40963
- var sv = (i << 4) | cd[i];
40964
- // free bits
40965
- var r_1 = mb - cd[i];
40966
- // start value
40967
- var v = le[cd[i] - 1]++ << r_1;
40968
- // m is end value
40969
- for (var m = v | ((1 << r_1) - 1); v <= m; ++v) {
40970
- // every 16 bit value starting with the code yields the same result
40971
- co[rev$1[v] >>> rvb] = sv;
40972
- }
40973
- }
40974
- }
40975
- }
40976
- else {
40977
- co = new u16$1(s);
40978
- for (i = 0; i < s; ++i) {
40979
- if (cd[i]) {
40980
- co[i] = rev$1[le[cd[i] - 1]++] >>> (15 - cd[i]);
40981
- }
40982
- }
40983
- }
40984
- return co;
40985
- });
40986
- // fixed length tree
40987
- var flt$1 = new u8$1(288);
40988
- for (var i$1 = 0; i$1 < 144; ++i$1)
40989
- flt$1[i$1] = 8;
40990
- for (var i$1 = 144; i$1 < 256; ++i$1)
40991
- flt$1[i$1] = 9;
40992
- for (var i$1 = 256; i$1 < 280; ++i$1)
40993
- flt$1[i$1] = 7;
40994
- for (var i$1 = 280; i$1 < 288; ++i$1)
40995
- flt$1[i$1] = 8;
40996
- // fixed distance tree
40997
- var fdt$1 = new u8$1(32);
40998
- for (var i$1 = 0; i$1 < 32; ++i$1)
40999
- fdt$1[i$1] = 5;
41000
- // fixed length map
41001
- var flrm$1 = /*#__PURE__*/ hMap$1(flt$1, 9, 1);
41002
- // fixed distance map
41003
- var fdrm$1 = /*#__PURE__*/ hMap$1(fdt$1, 5, 1);
41004
- // find max of array
41005
- var max$1 = function (a) {
41006
- var m = a[0];
41007
- for (var i = 1; i < a.length; ++i) {
41008
- if (a[i] > m)
41009
- m = a[i];
41010
- }
41011
- return m;
41012
- };
41013
- // read d, starting at bit p and mask with m
41014
- var bits$1 = function (d, p, m) {
41015
- var o = (p / 8) | 0;
41016
- return ((d[o] | (d[o + 1] << 8)) >> (p & 7)) & m;
41017
- };
41018
- // read d, starting at bit p continuing for at least 16 bits
41019
- var bits16$1 = function (d, p) {
41020
- var o = (p / 8) | 0;
41021
- return ((d[o] | (d[o + 1] << 8) | (d[o + 2] << 16)) >> (p & 7));
41022
- };
41023
- // get end of byte
41024
- var shft$1 = function (p) { return ((p + 7) / 8) | 0; };
41025
- // typed array slice - allows garbage collector to free original reference,
41026
- // while being more compatible than .slice
41027
- var slc$1 = function (v, s, e) {
41028
- if (s == null || s < 0)
41029
- s = 0;
41030
- if (e == null || e > v.length)
41031
- e = v.length;
41032
- // can't use .constructor in case user-supplied
41033
- var n = new (v.BYTES_PER_ELEMENT == 2 ? u16$1 : v.BYTES_PER_ELEMENT == 4 ? u32 : u8$1)(e - s);
41034
- n.set(v.subarray(s, e));
41035
- return n;
41036
- };
41037
- // error codes
41038
- var ec$1 = [
41039
- 'unexpected EOF',
41040
- 'invalid block type',
41041
- 'invalid length/literal',
41042
- 'invalid distance',
41043
- 'stream finished',
41044
- 'no stream handler',
41045
- ,
41046
- 'no callback',
41047
- 'invalid UTF-8 data',
41048
- 'extra field too long',
41049
- 'date not in range 1980-2099',
41050
- 'filename too long',
41051
- 'stream finishing',
41052
- 'invalid zip data'
41053
- // determined by unknown compression method
41054
- ];
41055
- var err$1 = function (ind, msg, nt) {
41056
- var e = new Error(msg || ec$1[ind]);
41057
- e.code = ind;
41058
- if (Error.captureStackTrace)
41059
- Error.captureStackTrace(e, err$1);
41060
- if (!nt)
41061
- throw e;
41062
- return e;
41063
- };
41064
- // expands raw DEFLATE data
41065
- var inflt$1 = function (dat, buf, st) {
41066
- // source length
41067
- var sl = dat.length;
41068
- if (!sl || (st && st.f && !st.l))
41069
- return buf || new u8$1(0);
41070
- // have to estimate size
41071
- var noBuf = !buf || st;
41072
- // no state
41073
- var noSt = !st || st.i;
41074
- if (!st)
41075
- st = {};
41076
- // Assumes roughly 33% compression ratio average
41077
- if (!buf)
41078
- buf = new u8$1(sl * 3);
41079
- // ensure buffer can fit at least l elements
41080
- var cbuf = function (l) {
41081
- var bl = buf.length;
41082
- // need to increase size to fit
41083
- if (l > bl) {
41084
- // Double or set to necessary, whichever is greater
41085
- var nbuf = new u8$1(Math.max(bl * 2, l));
41086
- nbuf.set(buf);
41087
- buf = nbuf;
41088
- }
41089
- };
41090
- // last chunk bitpos bytes
41091
- var final = st.f || 0, pos = st.p || 0, bt = st.b || 0, lm = st.l, dm = st.d, lbt = st.m, dbt = st.n;
41092
- // total bits
41093
- var tbts = sl * 8;
41094
- do {
41095
- if (!lm) {
41096
- // BFINAL - this is only 1 when last chunk is next
41097
- final = bits$1(dat, pos, 1);
41098
- // type: 0 = no compression, 1 = fixed huffman, 2 = dynamic huffman
41099
- var type = bits$1(dat, pos + 1, 3);
41100
- pos += 3;
41101
- if (!type) {
41102
- // go to end of byte boundary
41103
- var s = shft$1(pos) + 4, l = dat[s - 4] | (dat[s - 3] << 8), t = s + l;
41104
- if (t > sl) {
41105
- if (noSt)
41106
- err$1(0);
41107
- break;
41108
- }
41109
- // ensure size
41110
- if (noBuf)
41111
- cbuf(bt + l);
41112
- // Copy over uncompressed data
41113
- buf.set(dat.subarray(s, t), bt);
41114
- // Get new bitpos, update byte count
41115
- st.b = bt += l, st.p = pos = t * 8, st.f = final;
41116
- continue;
41117
- }
41118
- else if (type == 1)
41119
- lm = flrm$1, dm = fdrm$1, lbt = 9, dbt = 5;
41120
- else if (type == 2) {
41121
- // literal lengths
41122
- var hLit = bits$1(dat, pos, 31) + 257, hcLen = bits$1(dat, pos + 10, 15) + 4;
41123
- var tl = hLit + bits$1(dat, pos + 5, 31) + 1;
41124
- pos += 14;
41125
- // length+distance tree
41126
- var ldt = new u8$1(tl);
41127
- // code length tree
41128
- var clt = new u8$1(19);
41129
- for (var i = 0; i < hcLen; ++i) {
41130
- // use index map to get real code
41131
- clt[clim$1[i]] = bits$1(dat, pos + i * 3, 7);
41132
- }
41133
- pos += hcLen * 3;
41134
- // code lengths bits
41135
- var clb = max$1(clt), clbmsk = (1 << clb) - 1;
41136
- // code lengths map
41137
- var clm = hMap$1(clt, clb, 1);
41138
- for (var i = 0; i < tl;) {
41139
- var r = clm[bits$1(dat, pos, clbmsk)];
41140
- // bits read
41141
- pos += r & 15;
41142
- // symbol
41143
- var s = r >>> 4;
41144
- // code length to copy
41145
- if (s < 16) {
41146
- ldt[i++] = s;
41147
- }
41148
- else {
41149
- // copy count
41150
- var c = 0, n = 0;
41151
- if (s == 16)
41152
- n = 3 + bits$1(dat, pos, 3), pos += 2, c = ldt[i - 1];
41153
- else if (s == 17)
41154
- n = 3 + bits$1(dat, pos, 7), pos += 3;
41155
- else if (s == 18)
41156
- n = 11 + bits$1(dat, pos, 127), pos += 7;
41157
- while (n--)
41158
- ldt[i++] = c;
41159
- }
41160
- }
41161
- // length tree distance tree
41162
- var lt = ldt.subarray(0, hLit), dt = ldt.subarray(hLit);
41163
- // max length bits
41164
- lbt = max$1(lt);
41165
- // max dist bits
41166
- dbt = max$1(dt);
41167
- lm = hMap$1(lt, lbt, 1);
41168
- dm = hMap$1(dt, dbt, 1);
41169
- }
41170
- else
41171
- err$1(1);
41172
- if (pos > tbts) {
41173
- if (noSt)
41174
- err$1(0);
41175
- break;
41176
- }
41177
- }
41178
- // Make sure the buffer can hold this + the largest possible addition
41179
- // Maximum chunk size (practically, theoretically infinite) is 2^17;
41180
- if (noBuf)
41181
- cbuf(bt + 131072);
41182
- var lms = (1 << lbt) - 1, dms = (1 << dbt) - 1;
41183
- var lpos = pos;
41184
- for (;; lpos = pos) {
41185
- // bits read, code
41186
- var c = lm[bits16$1(dat, pos) & lms], sym = c >>> 4;
41187
- pos += c & 15;
41188
- if (pos > tbts) {
41189
- if (noSt)
41190
- err$1(0);
41191
- break;
41192
- }
41193
- if (!c)
41194
- err$1(2);
41195
- if (sym < 256)
41196
- buf[bt++] = sym;
41197
- else if (sym == 256) {
41198
- lpos = pos, lm = null;
41199
- break;
41200
- }
41201
- else {
41202
- var add = sym - 254;
41203
- // no extra bits needed if less
41204
- if (sym > 264) {
41205
- // index
41206
- var i = sym - 257, b = fleb$1[i];
41207
- add = bits$1(dat, pos, (1 << b) - 1) + fl$1[i];
41208
- pos += b;
41209
- }
41210
- // dist
41211
- var d = dm[bits16$1(dat, pos) & dms], dsym = d >>> 4;
41212
- if (!d)
41213
- err$1(3);
41214
- pos += d & 15;
41215
- var dt = fd$1[dsym];
41216
- if (dsym > 3) {
41217
- var b = fdeb$1[dsym];
41218
- dt += bits16$1(dat, pos) & ((1 << b) - 1), pos += b;
41219
- }
41220
- if (pos > tbts) {
41221
- if (noSt)
41222
- err$1(0);
41223
- break;
41224
- }
41225
- if (noBuf)
41226
- cbuf(bt + 131072);
41227
- var end = bt + add;
41228
- for (; bt < end; bt += 4) {
41229
- buf[bt] = buf[bt - dt];
41230
- buf[bt + 1] = buf[bt + 1 - dt];
41231
- buf[bt + 2] = buf[bt + 2 - dt];
41232
- buf[bt + 3] = buf[bt + 3 - dt];
41233
- }
41234
- bt = end;
41235
- }
41236
- }
41237
- st.l = lm, st.p = lpos, st.b = bt, st.f = final;
41238
- if (lm)
41239
- final = 1, st.m = lbt, st.d = dm, st.n = dbt;
41240
- } while (!final);
41241
- return bt == buf.length ? buf : slc$1(buf, 0, bt);
41242
- };
41243
- // empty
41244
- var et$1 = /*#__PURE__*/ new u8$1(0);
41245
- // read 2 bytes
41246
- var b2 = function (d, b) { return d[b] | (d[b + 1] << 8); };
41247
- // read 4 bytes
41248
- var b4 = function (d, b) { return (d[b] | (d[b + 1] << 8) | (d[b + 2] << 16) | (d[b + 3] << 24)) >>> 0; };
41249
- var b8 = function (d, b) { return b4(d, b) + (b4(d, b + 4) * 4294967296); };
41250
- /**
41251
- * Expands DEFLATE data with no wrapper
41252
- * @param data The data to decompress
41253
- * @param out Where to write the data. Saves memory if you know the decompressed size and provide an output buffer of that length.
41254
- * @returns The decompressed version of the data
41255
- */
41256
- function inflateSync(data, out) {
41257
- return inflt$1(data, out);
41258
- }
41259
- // text decoder
41260
- var td$1 = typeof TextDecoder != 'undefined' && /*#__PURE__*/ new TextDecoder();
41261
- // text decoder stream
41262
- var tds$1 = 0;
41263
- try {
41264
- td$1.decode(et$1, { stream: true });
41265
- tds$1 = 1;
41266
- }
41267
- catch (e) { }
41268
- // decode UTF8
41269
- var dutf8 = function (d) {
41270
- for (var r = '', i = 0;;) {
41271
- var c = d[i++];
41272
- var eb = (c > 127) + (c > 223) + (c > 239);
41273
- if (i + eb > d.length)
41274
- return [r, slc$1(d, i - 1)];
41275
- if (!eb)
41276
- r += String.fromCharCode(c);
41277
- else if (eb == 3) {
41278
- c = ((c & 15) << 18 | (d[i++] & 63) << 12 | (d[i++] & 63) << 6 | (d[i++] & 63)) - 65536,
41279
- r += String.fromCharCode(55296 | (c >> 10), 56320 | (c & 1023));
41280
- }
41281
- else if (eb & 1)
41282
- r += String.fromCharCode((c & 31) << 6 | (d[i++] & 63));
41283
- else
41284
- r += String.fromCharCode((c & 15) << 12 | (d[i++] & 63) << 6 | (d[i++] & 63));
41285
- }
41286
- };
41287
- /**
41288
- * Converts a Uint8Array to a string
41289
- * @param dat The data to decode to string
41290
- * @param latin1 Whether or not to interpret the data as Latin-1. This should
41291
- * not need to be true unless encoding to binary string.
41292
- * @returns The original UTF-8/Latin-1 string
41293
- */
41294
- function strFromU8(dat, latin1) {
41295
- if (latin1) {
41296
- var r = '';
41297
- for (var i = 0; i < dat.length; i += 16384)
41298
- r += String.fromCharCode.apply(null, dat.subarray(i, i + 16384));
41299
- return r;
41300
- }
41301
- else if (td$1)
41302
- return td$1.decode(dat);
41303
- else {
41304
- var _a = dutf8(dat), out = _a[0], ext = _a[1];
41305
- if (ext.length)
41306
- err$1(8);
41307
- return out;
41308
- }
41309
- }
41310
- // skip local zip header
41311
- var slzh = function (d, b) { return b + 30 + b2(d, b + 26) + b2(d, b + 28); };
41312
- // read zip header
41313
- var zh = function (d, b, z) {
41314
- var fnl = b2(d, b + 28), fn = strFromU8(d.subarray(b + 46, b + 46 + fnl), !(b2(d, b + 8) & 2048)), es = b + 46 + fnl, bs = b4(d, b + 20);
41315
- var _a = z && bs == 4294967295 ? z64e(d, es) : [bs, b4(d, b + 24), b4(d, b + 42)], sc = _a[0], su = _a[1], off = _a[2];
41316
- return [b2(d, b + 10), sc, su, fn, es + b2(d, b + 30) + b2(d, b + 32), off];
41317
- };
41318
- // read zip64 extra field
41319
- var z64e = function (d, b) {
41320
- for (; b2(d, b) != 1; b += 4 + b2(d, b + 2))
41321
- ;
41322
- return [b8(d, b + 12), b8(d, b + 4), b8(d, b + 20)];
41323
- };
41324
- /**
41325
- * Synchronously decompresses a ZIP archive. Prefer using `unzip` for better
41326
- * performance with more than one file.
41327
- * @param data The raw compressed ZIP file
41328
- * @param opts The ZIP extraction options
41329
- * @returns The decompressed files
41330
- */
41331
- function unzipSync(data, opts) {
41332
- var files = {};
41333
- var e = data.length - 22;
41334
- for (; b4(data, e) != 0x6054B50; --e) {
41335
- if (!e || data.length - e > 65558)
41336
- err$1(13);
41337
- }
41338
- var c = b2(data, e + 8);
41339
- if (!c)
41340
- return {};
41341
- var o = b4(data, e + 16);
41342
- var z = o == 4294967295 || c == 65535;
41343
- if (z) {
41344
- var ze = b4(data, e - 12);
41345
- z = b4(data, ze) == 0x6064B50;
41346
- if (z) {
41347
- c = b4(data, ze + 32);
41348
- o = b4(data, ze + 48);
41349
- }
41350
- }
41351
- var fltr = opts && opts.filter;
41352
- for (var i = 0; i < c; ++i) {
41353
- var _a = zh(data, o, z), c_2 = _a[0], sc = _a[1], su = _a[2], fn = _a[3], no = _a[4], off = _a[5], b = slzh(data, off);
41354
- o = no;
41355
- if (!fltr || fltr({
41356
- name: fn,
41357
- size: sc,
41358
- originalSize: su,
41359
- compression: c_2
41360
- })) {
41361
- if (!c_2)
41362
- files[fn] = slc$1(data, b, b + sc);
41363
- else if (c_2 == 8)
41364
- files[fn] = inflateSync(data.subarray(b, b + sc), new u8$1(su));
41365
- else
41366
- err$1(14, 'unknown compression type ' + c_2);
41367
- }
41368
- }
41369
- return files;
41370
- }
41371
-
41372
- var vert_2d = "#define GLSLIFY 1\nuniform vec2 size;out vec2 vUv;void main(){gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);vUv.xy=position.xy/size+0.5;vUv.y=1.0-vUv.y;}"; // eslint-disable-line
41373
-
41374
- var frag_2d = "precision highp float;precision highp int;precision highp sampler2DArray;\n#define GLSLIFY 1\nuniform sampler2DArray diffuse;in vec2 vUv;uniform int depth;out vec4 outColor;void main(){vec4 color=texture(diffuse,vec3(vUv,depth));outColor=vec4(color.rrr*1.5,1.0);}"; // eslint-disable-line
40942
+ var frag_2d = "precision highp float;precision highp int;precision highp sampler2DArray;\n#define GLSLIFY 1\nuniform sampler2DArray diffuse;in vec2 vUv;uniform int depth;uniform float uOpacity;out vec4 outColor;void main(){vec4 color=texture(diffuse,vec3(vUv,depth));outColor=vec4(color.rrr*1.5,uOpacity);}"; // eslint-disable-line
41375
40943
 
41376
40944
  let planeWidth = 80;
41377
40945
  let planeHeight = 80;
41378
- function createTexture2D_Zip(url, scene) {
41379
- new FileLoader()
41380
- .setResponseType("arraybuffer")
41381
- .load(url, function (data) {
41382
- const zip = unzipSync(new Uint8Array(data));
41383
- const array = new Uint8Array(zip["head256x256x109"].buffer);
41384
- const texture = new DataArrayTexture(array, 256, 256, 109);
41385
- texture.format = RedFormat;
41386
- texture.needsUpdate = true;
41387
- const material = new ShaderMaterial({
41388
- uniforms: {
41389
- diffuse: { value: texture },
41390
- depth: { value: 1 },
41391
- size: { value: new Vector2(planeWidth, planeHeight) },
41392
- },
41393
- vertexShader: vert_2d,
41394
- fragmentShader: frag_2d,
41395
- glslVersion: GLSL3,
41396
- side: DoubleSide,
41397
- });
41398
- const geometry = new PlaneGeometry(planeWidth, planeHeight);
41399
- const mesh = new Mesh(geometry, material);
41400
- mesh.name = "texture2d_mesh_zip";
41401
- scene.add(mesh);
41402
- });
40946
+ // Build a quad from the 4 world-space image-plane corners (preserves real pose).
40947
+ function buildAlignedQuad(c) {
40948
+ const g = new BufferGeometry();
40949
+ // two triangles: tl,bl,tr / tr,bl,br
40950
+ g.setAttribute("position", new BufferAttribute(new Float32Array([...c.tl, ...c.bl, ...c.tr, ...c.tr, ...c.bl, ...c.br]), 3));
40951
+ // vertex order is tl, bl, tr, tr, bl, br. The fragment shader flips v (vUv.y = 1-uv.y)
40952
+ // and DataArrayTexture has no flipY, so the image-top (data row 0) must map to the
40953
+ // top world corner: tl/tr get uv.y = 1.
40954
+ g.setAttribute("uv", new BufferAttribute(new Float32Array([0, 1, 0, 0, 1, 1, 1, 1, 0, 0, 1, 0]), 2));
40955
+ g.computeVertexNormals();
40956
+ return g;
41403
40957
  }
41404
40958
  function createTexture2D_NRRD(data, width, height, depth, callback) {
41405
40959
  const texture = new DataArrayTexture(data, width, height, depth);
@@ -41409,7 +40963,7 @@ void main() {
41409
40963
  uniforms: {
41410
40964
  diffuse: { value: texture },
41411
40965
  depth: { value: 1 },
41412
- size: { value: new Vector2(planeWidth, planeHeight) },
40966
+ uOpacity: { value: 1 },
41413
40967
  },
41414
40968
  vertexShader: vert_2d,
41415
40969
  fragmentShader: frag_2d,
@@ -41421,13 +40975,9 @@ void main() {
41421
40975
  mesh.name = "texture2d_mesh_zip";
41422
40976
  callback(mesh);
41423
40977
  }
41424
- function createTexture2D_Array(copperVolume, depth, scene, gui) {
40978
+ function createTexture2D_Array(copperVolume, depth, scene, gui, aligned = false) {
41425
40979
  planeWidth = copperVolume.width / 2;
41426
40980
  planeHeight = copperVolume.height / 2;
41427
- ({
41428
- windowWidth: copperVolume.windowWidth,
41429
- windowCenter: copperVolume.windowCenter,
41430
- });
41431
40981
  const texture = new DataArrayTexture(copperVolume.uint8, copperVolume.width, copperVolume.height, depth);
41432
40982
  texture.format = RedFormat;
41433
40983
  texture.needsUpdate = true;
@@ -41454,18 +41004,22 @@ void main() {
41454
41004
  const material = new ShaderMaterial({
41455
41005
  uniforms: {
41456
41006
  diffuse: { value: texture },
41457
- depth: { value: 1 },
41458
- size: { value: new Vector2(planeWidth, planeHeight) },
41007
+ depth: { value: 0 },
41008
+ uOpacity: { value: 1 },
41459
41009
  },
41460
41010
  vertexShader: vert_2d,
41461
41011
  fragmentShader: frag_2d,
41462
41012
  glslVersion: GLSL3,
41463
41013
  side: DoubleSide,
41464
41014
  });
41465
- const geometry = new PlaneGeometry(planeWidth, planeHeight);
41015
+ // Aligned world-space quad only when explicitly requested and geometry tags exist;
41016
+ // otherwise the legacy centred plane (keeps existing loadDicom behaviour unchanged).
41017
+ const geometry = aligned && copperVolume.corners
41018
+ ? buildAlignedQuad(copperVolume.corners)
41019
+ : new PlaneGeometry(planeWidth, planeHeight);
41466
41020
  const mesh = new Mesh(geometry, material);
41467
- scene.add(mesh);
41468
41021
  mesh.name = "texture2d_mesh_array";
41022
+ scene.add(mesh);
41469
41023
  function updateTexture(copperVolumeUp) {
41470
41024
  if (!!copperVolumeUp) {
41471
41025
  let voiLUT;
@@ -41476,7 +41030,15 @@ void main() {
41476
41030
  texture.needsUpdate = true;
41477
41031
  }
41478
41032
  }
41479
- return { mesh, copperVolume, updateTexture };
41033
+ function setFrame(i) {
41034
+ material.uniforms.depth.value = i;
41035
+ }
41036
+ function setWindow(center, width) {
41037
+ copperVolume.windowCenter = center;
41038
+ copperVolume.windowWidth = width;
41039
+ updateTexture(copperVolume);
41040
+ }
41041
+ return { mesh, copperVolume, updateTexture, setFrame, setWindow };
41480
41042
  }
41481
41043
 
41482
41044
  /*!
@@ -50532,6 +50094,7 @@ void main() {
50532
50094
  class baseRenderer {
50533
50095
  constructor(container, options) {
50534
50096
  var _a, _b, _c, _d, _e;
50097
+ this.running = true;
50535
50098
  this.visualCtrls = [];
50536
50099
  this.container = container;
50537
50100
  this.options = options;
@@ -50632,6 +50195,25 @@ void main() {
50632
50195
  setClearColor(clearColor = 0x000000, alpha = 0) {
50633
50196
  this.renderer.setClearColor(clearColor, alpha);
50634
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
+ }
50635
50217
  addGui() {
50636
50218
  var _a, _b, _c;
50637
50219
  const gui = (this.gui = new GUI$1({
@@ -59308,6 +58890,7 @@ void main() {
59308
58890
  }
59309
58891
 
59310
58892
  class copperScene extends baseScene {
58893
+ // rayster pick
59311
58894
  constructor(container, renderer, opt) {
59312
58895
  super(container, renderer, opt);
59313
58896
  this.clock = new Clock();
@@ -59315,9 +58898,6 @@ void main() {
59315
58898
  this.mixer = null;
59316
58899
  this.playRate = 1.0;
59317
58900
  this.modelReady = false;
59318
- // rayster pick
59319
- // texture2d
59320
- this.texture2dMesh = null;
59321
58901
  if ((opt === null || opt === void 0 ? void 0 : opt.controls) === "trackball") {
59322
58902
  this.controls = new TrackballControls(this.camera, this.renderer.domElement);
59323
58903
  }
@@ -59523,38 +59103,177 @@ void main() {
59523
59103
  this.exportContent.animations.push(clip);
59524
59104
  };
59525
59105
  }
59526
- // texture2d
59527
- texture2d(url) {
59528
- createTexture2D_Zip(url, this.scene);
59529
- const textureInterval = setInterval(() => {
59530
- var _a;
59531
- this.scene.children.forEach((child) => {
59532
- if (child.isMesh) {
59533
- if (child.name === "texture2d_mesh_zip") {
59534
- this.texture2dMesh = child;
59535
- const render_texture2d = () => {
59536
- if (this.texture2dMesh) {
59537
- let value = this.texture2dMesh.material.uniforms["depth"].value;
59538
- value += this.depthStep;
59539
- if (value > 109.0 || value < 0.0) {
59540
- if (value > 1.0)
59541
- value = 109.0 * 2.0 - value;
59542
- if (value < 0.0)
59543
- value = -value;
59544
- this.depthStep = -this.depthStep;
59545
- }
59546
- this.texture2dMesh.material.uniforms["depth"].value =
59547
- value;
59548
- }
59549
- };
59550
- this.addPreRenderCallbackFunction(render_texture2d);
59551
- }
59552
- }
59106
+ /**
59107
+ * Load an aligned 4D scene: a cine MRI (one slice, N cardiac phases) rendered as a
59108
+ * world-placed plane, plus 0..N deforming surface sequences (e.g. LV endo/epi). MRI
59109
+ * and surfaces stay in their shared patient coordinate frame (no center/scale), and
59110
+ * advance together off ONE shared frame clock. Returns a controller (play/pause/...).
59111
+ */
59112
+ loadAligned4D(opts, callback) {
59113
+ var _a;
59114
+ const dicomDepth = opts.dicomUrls.length;
59115
+ const loadDicomStack = () => new Promise((resolve) => {
59116
+ const volumes = [];
59117
+ opts.dicomUrls.forEach((url) => {
59118
+ copperDicomLoader(url, (volume) => {
59119
+ volumes.push(volume);
59120
+ if (volumes.length !== dicomDepth)
59121
+ return;
59122
+ // order frames by cardiac phase (TriggerTime / SliceLocation)
59123
+ volumes.sort((a, b) => a.order - b.order);
59124
+ const frameSize = volumes[0].width * volumes[0].height;
59125
+ const uint8 = new Uint8ClampedArray(frameSize * dicomDepth);
59126
+ const uint16 = new Uint16Array(uint8.length);
59127
+ volumes.forEach((v, index) => {
59128
+ uint8.set(v.uint8, index * frameSize);
59129
+ uint16.set(v.uint16, index * frameSize);
59130
+ });
59131
+ const stacked = Object.assign(Object.assign({}, volumes[0]), { uint8,
59132
+ uint16 });
59133
+ resolve(stacked);
59134
+ });
59135
+ });
59136
+ });
59137
+ const loadSurfaceSequence = (urls) => new Promise((resolve) => {
59138
+ const loader = new VTKLoader();
59139
+ const geometries = new Array(urls.length);
59140
+ let done = 0;
59141
+ urls.forEach((url, index) => {
59142
+ loader.load(url, (geometry) => {
59143
+ // keep world coordinates — no center()/scale()
59144
+ geometry.computeVertexNormals();
59145
+ geometries[index] = geometry;
59146
+ if (++done === urls.length)
59147
+ resolve(geometries);
59148
+ });
59553
59149
  });
59554
- if (((_a = this.texture2dMesh) === null || _a === void 0 ? void 0 : _a.name) === "texture2d_mesh_zip") {
59555
- clearInterval(textureInterval);
59150
+ });
59151
+ const surfaceDefs = (_a = opts.surfaces) !== null && _a !== void 0 ? _a : [];
59152
+ Promise.all([
59153
+ loadDicomStack(),
59154
+ ...surfaceDefs.map((s) => loadSurfaceSequence(s.urls)),
59155
+ ]).then(([stacked, ...surfaceGeoms]) => {
59156
+ var _a;
59157
+ if (opts.window) {
59158
+ stacked.windowCenter = opts.window.center;
59159
+ stacked.windowWidth = opts.window.width;
59556
59160
  }
59557
- }, 500);
59161
+ const tex = createTexture2D_Array(stacked, dicomDepth, this.scene, undefined, true);
59162
+ tex.setFrame(0);
59163
+ if (opts.window)
59164
+ tex.setWindow(opts.window.center, opts.window.width);
59165
+ const surfaceMeshes = {};
59166
+ const seqs = [];
59167
+ surfaceDefs.forEach((def, i) => {
59168
+ var _a;
59169
+ const geometries = surfaceGeoms[i];
59170
+ const { vtkmaterial } = copperMultipleVtk(def.opts);
59171
+ const mesh = new Mesh(geometries[0], vtkmaterial);
59172
+ mesh.name = def.name;
59173
+ this.scene.add(mesh);
59174
+ surfaceMeshes[def.name] = mesh;
59175
+ seqs.push({ mesh, geometries, offset: (_a = def.offset) !== null && _a !== void 0 ? _a : 0 });
59176
+ });
59177
+ const frameCount = dicomDepth;
59178
+ // Default one cardiac cycle ≈ 1012ms (32 phases × ~31.6ms) unless overridden.
59179
+ const cycleMs = (_a = opts.cycleMs) !== null && _a !== void 0 ? _a : 1012;
59180
+ let frameIndex = 0;
59181
+ let speed = 1;
59182
+ let playing = true;
59183
+ let disposed = false;
59184
+ let lastStep = performance.now();
59185
+ const dtBase = cycleMs / frameCount;
59186
+ const applyFrame = () => {
59187
+ tex.setFrame(frameIndex);
59188
+ for (const s of seqs) {
59189
+ const i = (frameIndex + s.offset + s.geometries.length) % s.geometries.length;
59190
+ s.mesh.geometry = s.geometries[i];
59191
+ }
59192
+ };
59193
+ const tick = () => {
59194
+ if (disposed || !playing)
59195
+ return;
59196
+ const now = performance.now();
59197
+ if (now - lastStep >= dtBase / speed) {
59198
+ lastStep = now;
59199
+ frameIndex = (frameIndex + 1) % frameCount;
59200
+ applyFrame();
59201
+ }
59202
+ };
59203
+ this.addPreRenderCallbackFunction(tick);
59204
+ const clockId = tick.id;
59205
+ const ctrl = {
59206
+ plane: tex.mesh,
59207
+ surfaceMeshes,
59208
+ frameCount,
59209
+ play: () => {
59210
+ playing = true;
59211
+ lastStep = performance.now();
59212
+ },
59213
+ pause: () => {
59214
+ playing = false;
59215
+ },
59216
+ toggle: () => {
59217
+ playing = !playing;
59218
+ lastStep = performance.now();
59219
+ },
59220
+ setSpeed: (x) => {
59221
+ speed = Math.max(0.01, x);
59222
+ },
59223
+ setFrame: (i) => {
59224
+ frameIndex = ((i % frameCount) + frameCount) % frameCount;
59225
+ applyFrame();
59226
+ },
59227
+ setFrameOffset: (name, n) => {
59228
+ const s = seqs.find((q) => q.mesh.name === name);
59229
+ if (s) {
59230
+ s.offset = n;
59231
+ applyFrame();
59232
+ }
59233
+ },
59234
+ setWindow: (center, width) => {
59235
+ tex.setWindow(center, width);
59236
+ },
59237
+ setPlaneOpacity: (v) => {
59238
+ const m = tex.mesh.material;
59239
+ m.transparent = v < 1;
59240
+ m.uniforms.uOpacity.value = v;
59241
+ m.needsUpdate = true;
59242
+ },
59243
+ setSurfaceOpacity: (name, v) => {
59244
+ const mesh = surfaceMeshes[name];
59245
+ if (!mesh)
59246
+ return;
59247
+ const m = mesh.material;
59248
+ m.transparent = v < 1;
59249
+ m.opacity = v;
59250
+ m.needsUpdate = true;
59251
+ },
59252
+ setSurfaceVisible: (name, visible) => {
59253
+ const mesh = surfaceMeshes[name];
59254
+ if (mesh)
59255
+ mesh.visible = visible;
59256
+ },
59257
+ dispose: () => {
59258
+ var _a, _b;
59259
+ disposed = true;
59260
+ this.removePreRenderCallbackFunction(clockId);
59261
+ // plane
59262
+ this.scene.remove(tex.mesh);
59263
+ tex.mesh.geometry.dispose();
59264
+ const pm = tex.mesh.material;
59265
+ (_b = (_a = pm.uniforms.diffuse.value) === null || _a === void 0 ? void 0 : _a.dispose) === null || _b === void 0 ? void 0 : _b.call(_a);
59266
+ pm.dispose();
59267
+ // surfaces
59268
+ for (const s of seqs) {
59269
+ this.scene.remove(s.mesh);
59270
+ s.geometries.forEach((g) => g.dispose());
59271
+ s.mesh.material.dispose();
59272
+ }
59273
+ },
59274
+ };
59275
+ callback && callback(ctrl);
59276
+ });
59558
59277
  }
59559
59278
  getPlayRate() {
59560
59279
  return this.playRate;
@@ -59662,6 +59381,8 @@ void main() {
59662
59381
  this.preRenderCallbackFunctions = [];
59663
59382
  this.animate = (time) => {
59664
59383
  var _a, _b, _c;
59384
+ if (!this.running)
59385
+ return;
59665
59386
  switch ((_a = this.options) === null || _a === void 0 ? void 0 : _a.fpsMode) {
59666
59387
  case "1":
59667
59388
  // fpsControl one: 30fps
@@ -60212,6 +59933,7 @@ void main() {
60212
59933
  alpha: true,
60213
59934
  antialias: true,
60214
59935
  });
59936
+ this.running = true;
60215
59937
  this.renderSceneInfo = (sceneInfo) => {
60216
59938
  const elem = sceneInfo.container;
60217
59939
  // get the viewpoint relative position of this element
@@ -60250,6 +59972,8 @@ void main() {
60250
59972
  }
60251
59973
  };
60252
59974
  this.animate = () => {
59975
+ if (!this.running)
59976
+ return;
60253
59977
  const clearColor = new Color$1("#000");
60254
59978
  this.renderer.setScissorTest(false);
60255
59979
  this.renderer.setClearColor(clearColor, 0);
@@ -60315,6 +60039,25 @@ void main() {
60315
60039
  }, undefined, reject);
60316
60040
  });
60317
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
+ }
60318
60061
  }
60319
60062
 
60320
60063
  /******************************************************************************
@@ -70734,6 +70477,36 @@ void main() {
70734
70477
  7: '#f97316',
70735
70478
  8: '#8b5cf6', // Violet
70736
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
+ };
70737
70510
  // ── Color Conversion Utilities ──────────────────────────────────────────
70738
70511
  /**
70739
70512
  * Convert an RGBAColor to a hex string (no alpha), e.g. '#ff0000'.
@@ -73882,6 +73655,8 @@ void main() {
73882
73655
  constructor(config) {
73883
73656
  // === State ===
73884
73657
  this.mode = 'idle';
73658
+ /** Mode to restore after a right-drag pan ends (so pan doesn't drop aiAssist). */
73659
+ this.modeBeforePan = 'idle';
73885
73660
  this.guiTool = 'pencil';
73886
73661
  this.state = {
73887
73662
  shiftHeld: false,
@@ -74007,7 +73782,20 @@ void main() {
74007
73782
  * Set the GUI tool (from UI buttons).
74008
73783
  */
74009
73784
  setGuiTool(tool) {
73785
+ const prevTool = this.guiTool;
74010
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
+ }
74011
73799
  // When entering any sphere-family tool, keep crosshair if active, otherwise idle
74012
73800
  if (SPHERE_TOOLS.has(tool)) {
74013
73801
  if (!this.state.crosshairEnabled) {
@@ -74021,8 +73809,9 @@ void main() {
74021
73809
  * Blocked when draw or contrast mode is active, or left button is held (mutual exclusion).
74022
73810
  */
74023
73811
  toggleCrosshair() {
74024
- // Allow crosshair in drawing tools and all sphere-family tools (sphere / sphereBrush / sphereEraser)
74025
- if (!DRAWING_TOOLS.has(this.guiTool) && !SPHERE_TOOLS.has(this.guiTool))
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')
74026
73815
  return;
74027
73816
  // Block crosshair activation during draw, contrast, or while left button held.
74028
73817
  // The leftButtonDown guard also enforces "once a sphere preview is on screen,
@@ -74030,7 +73819,15 @@ void main() {
74030
73819
  if (this.state.shiftHeld || this.state.leftButtonDown || this.mode === 'draw' || this.mode === 'contrast')
74031
73820
  return;
74032
73821
  this.state.crosshairEnabled = !this.state.crosshairEnabled;
74033
- this.setMode(this.state.crosshairEnabled ? 'crosshair' : 'idle');
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
+ }
74034
73831
  }
74035
73832
  /**
74036
73833
  * Check if crosshair mode is enabled.
@@ -74166,8 +73963,8 @@ void main() {
74166
73963
  }
74167
73964
  }
74168
73965
  if (this.contrastEnabled && this.keyboardSettings.contrast.includes(ev.key)) {
74169
- // Block contrast state when crosshair, draw, or any sphere-family tool is active (mutual exclusion)
74170
- 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'
74171
73968
  && !SPHERE_TOOLS.has(this.guiTool)) {
74172
73969
  this.state.ctrlHeld = true;
74173
73970
  }
@@ -74204,6 +74001,10 @@ void main() {
74204
74001
  }
74205
74002
  else if (ev.button === 2) {
74206
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;
74207
74008
  this.setMode('pan');
74208
74009
  }
74209
74010
  // Route to external handler
@@ -74224,7 +74025,10 @@ void main() {
74224
74025
  else if (ev.button === 2) {
74225
74026
  this.state.rightButtonDown = false;
74226
74027
  if (this.mode === 'pan') {
74227
- this.setMode('idle');
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';
74228
74032
  }
74229
74033
  }
74230
74034
  // Route to external handler
@@ -76147,6 +75951,474 @@ void main() {
76147
75951
  }
76148
75952
  }
76149
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
+
76150
76422
  /**
76151
76423
  * DrawToolCore — Tool orchestration and event routing.
76152
76424
  *
@@ -76267,6 +76539,10 @@ void main() {
76267
76539
  setEmptyCanvasSize: (axis) => this.setEmptyCanvasSize(axis),
76268
76540
  reloadMasksFromVolume: () => this.reloadMasksFromVolume(),
76269
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
+ });
76270
76546
  }
76271
76547
  initDrawToolCore() {
76272
76548
  // Initialize EventRouter for centralized event handling
@@ -76373,6 +76649,8 @@ void main() {
76373
76649
  // Block contrast toggle during crosshair, draw, or sphere (mutual exclusion)
76374
76650
  if (this.eventRouter.isCrosshairEnabled() || this.eventRouter.getMode() === 'draw')
76375
76651
  return;
76652
+ if (this.eventRouter.getMode() === 'aiAssist')
76653
+ return;
76376
76654
  if (this.state.gui_states.mode.sphere)
76377
76655
  return;
76378
76656
  // Toggle contrast mode manually since it's on keyup
@@ -76386,6 +76664,10 @@ void main() {
76386
76664
  });
76387
76665
  // Register pointer handlers with EventRouter
76388
76666
  this.eventRouter.setPointerMoveHandler((e) => {
76667
+ if (this.eventRouter.getMode() === 'aiAssist') {
76668
+ this.aiAssistTool.onPointerMove(e);
76669
+ return;
76670
+ }
76389
76671
  if (this.drawingTool.isActive || this.panTool.isActive) {
76390
76672
  this.drawingPrameters.handleOnDrawingMouseMove(e);
76391
76673
  }
@@ -76400,6 +76682,14 @@ void main() {
76400
76682
  }
76401
76683
  });
76402
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
+ }
76403
76693
  // Restore Scroll:Zoom if we swapped to Scroll:Slice on drag-start.
76404
76694
  // Must run outside the drawing-tool gate below: slice-drag doesn't
76405
76695
  // flip any of those flags, so the gate would skip restoration.
@@ -76559,7 +76849,10 @@ void main() {
76559
76849
  this.activeWheelMode = 'none';
76560
76850
  }
76561
76851
  if (e.button === 0) {
76562
- if (this.eventRouter.getMode() === 'draw') {
76852
+ if (this.eventRouter.getMode() === 'aiAssist') {
76853
+ this.aiAssistTool.onPointerDown(e);
76854
+ }
76855
+ else if (this.eventRouter.getMode() === 'draw') {
76563
76856
  this.drawingTool.onPointerDown(e);
76564
76857
  }
76565
76858
  else if (this.eventRouter.isCrosshairEnabled()) {
@@ -76674,6 +76967,12 @@ void main() {
76674
76967
  || this.state.gui_states.mode.sphereEraser) {
76675
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);
76676
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
+ }
76677
76976
  }
76678
76977
  else {
76679
76978
  this.redrawDisplayCanvas();
@@ -78278,6 +78577,13 @@ void main() {
78278
78577
  this._calculatorActive = false;
78279
78578
  /** Stored closure callbacks from gui.ts setupGui() */
78280
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;
78281
78587
  this.container = container;
78282
78588
  // Create shared state
78283
78589
  const mainAreaContainer = document.createElement("div");
@@ -78445,6 +78751,13 @@ void main() {
78445
78751
  var _a, _b, _c;
78446
78752
  if (!this.guiCallbacks)
78447
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
+ }
78448
78761
  const prevSphere = this.state.gui_states.mode.sphere;
78449
78762
  const prevSphereBrush = this.state.gui_states.mode.sphereBrush;
78450
78763
  const prevSphereEraser = this.state.gui_states.mode.sphereEraser;
@@ -79093,6 +79406,111 @@ void main() {
79093
79406
  this.resetLayerCanvas();
79094
79407
  this.reloadMasksFromVolume();
79095
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
+ }
79096
79514
  // ═══════════════════════════════════════════════════════════════════════════
79097
79515
  // 11. Clear / Reset
79098
79516
  // ═══════════════════════════════════════════════════════════════════════════
@@ -79456,9 +79874,11 @@ void main() {
79456
79874
  }
79457
79875
 
79458
79876
  // import * as kiwrious from "copper3d_plugin_heart_k";
79459
- const REVISION = "v3.4.8-beta";
79877
+ const REVISION = "v3.5.0-beta";
79460
79878
  console.log(`%cCopper3D Visualisation %cBeta:${REVISION}`, "padding: 3px;color:white; background:#023047", "padding: 3px;color:white; background:#f50a25");
79461
79879
 
79880
+ exports.AI_CHANNEL_HEX_COLORS = AI_CHANNEL_HEX_COLORS;
79881
+ exports.AI_MASK_CHANNEL_COLORS = AI_MASK_CHANNEL_COLORS;
79462
79882
  exports.CHANNEL_COLORS = CHANNEL_COLORS;
79463
79883
  exports.CHANNEL_HEX_COLORS = CHANNEL_HEX_COLORS;
79464
79884
  exports.CameraViewPoint = CameraViewPoint;