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
@@ -40826,6 +40826,17 @@ function copperDicomLoader(url, callback) {
40826
40826
  windowCenter = 226;
40827
40827
  windowWidth = 537;
40828
40828
  }
40829
+ // Spatial geometry tags (DS VR, backslash-delimited) used to place the image plane
40830
+ // at its true world pose, plus InstanceNumber as an ordering fallback.
40831
+ const ipp = parseDS(dataSet.string("x00200032")); // ImagePositionPatient
40832
+ const iop = parseDS(dataSet.string("x00200037")); // ImageOrientationPatient
40833
+ const spacing = parseDS(dataSet.string("x00280030")); // PixelSpacing
40834
+ let instanceNumber = parseInt(dataSet.string("x00200013"));
40835
+ if (Number.isNaN(instanceNumber))
40836
+ instanceNumber = undefined;
40837
+ const corners = ipp && iop && spacing
40838
+ ? computeImagePlaneCorners(ipp, iop, spacing, w, h)
40839
+ : undefined;
40829
40840
  let pixelData = dataSet.elements.x7fe00010;
40830
40841
  let uint16 = convertImplicitElement(pixelData, dicomFileAsBuffer);
40831
40842
  let voiLUT;
@@ -40844,10 +40855,44 @@ function copperDicomLoader(url, callback) {
40844
40855
  uint16,
40845
40856
  uint8,
40846
40857
  order,
40858
+ instanceNumber,
40859
+ imagePositionPatient: ipp,
40860
+ imageOrientationPatient: iop,
40861
+ pixelSpacing: spacing,
40862
+ corners,
40847
40863
  };
40848
40864
  callback && callback(copperVolume);
40849
40865
  });
40850
40866
  }
40867
+ // Parse a DICOM DS/IS multi-value string ("a\\b\\c") into a number[].
40868
+ function parseDS(s) {
40869
+ if (!s)
40870
+ return undefined;
40871
+ const a = s.split("\\").map((v) => parseFloat(v));
40872
+ return a.length && !a.some((n) => Number.isNaN(n)) ? a : undefined;
40873
+ }
40874
+ /**
40875
+ * Compute the 4 world-space corners of a DICOM image plane from its geometry tags.
40876
+ * IPP is the CENTER of pixel (0,0), so corners sit half a pixel out. PixelSpacing is
40877
+ * [rowSpacing, colSpacing]; IOP's first vector is the +column-index direction.
40878
+ */
40879
+ function computeImagePlaneCorners(ipp, iop, spacing, cols, rows) {
40880
+ const row = new Vector3(iop[0], iop[1], iop[2]); // +column-index dir
40881
+ const col = new Vector3(iop[3], iop[4], iop[5]); // +row-index dir
40882
+ const sc = spacing[1]; // column spacing (along row)
40883
+ const sr = spacing[0]; // row spacing (along col)
40884
+ const O = new Vector3(ipp[0], ipp[1], ipp[2]); // center of pixel (0,0)
40885
+ const p = (a, b) => O.clone()
40886
+ .addScaledVector(row, a * sc)
40887
+ .addScaledVector(col, b * sr)
40888
+ .toArray();
40889
+ return {
40890
+ tl: p(-0.5, -0.5),
40891
+ tr: p(cols - 0.5, -0.5),
40892
+ bl: p(-0.5, rows - 0.5),
40893
+ br: p(cols - 0.5, rows - 0.5),
40894
+ };
40895
+ }
40851
40896
  function convertImplicitElement(elementData, dicomFileAsBuffer) {
40852
40897
  let w1Buffer = dicomParser.sharedCopy(dicomFileAsBuffer, elementData.dataOffset, elementData.length);
40853
40898
  let unit16 = new Uint16Array(w1Buffer.buffer, w1Buffer.byteOffset, w1Buffer.byteLength / Uint16Array.BYTES_PER_ELEMENT);
@@ -40884,514 +40929,23 @@ function getLut(data, windowWidth, windowCenter, invert, voiLUT) {
40884
40929
  };
40885
40930
  }
40886
40931
 
40887
- // DEFLATE is a complex format; to read this code, you should probably check the RFC first:
40932
+ 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
40888
40933
 
40889
- // aliases for shorter compressed code (most minifers don't do this)
40890
- var u8$1 = Uint8Array, u16$1 = Uint16Array, u32 = Uint32Array;
40891
- // fixed length extra bits
40892
- 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]);
40893
- // fixed distance extra bits
40894
- // see fleb note
40895
- 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]);
40896
- // code length index map
40897
- 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]);
40898
- // get base, reverse index map from extra bits
40899
- var freb$1 = function (eb, start) {
40900
- var b = new u16$1(31);
40901
- for (var i = 0; i < 31; ++i) {
40902
- b[i] = start += 1 << eb[i - 1];
40903
- }
40904
- // numbers here are at max 18 bits
40905
- var r = new u32(b[30]);
40906
- for (var i = 1; i < 30; ++i) {
40907
- for (var j = b[i]; j < b[i + 1]; ++j) {
40908
- r[j] = ((j - b[i]) << 5) | i;
40909
- }
40910
- }
40911
- return [b, r];
40912
- };
40913
- var _a$1 = freb$1(fleb$1, 2), fl$1 = _a$1[0], revfl$1 = _a$1[1];
40914
- // we can ignore the fact that the other numbers are wrong; they never happen anyway
40915
- fl$1[28] = 258, revfl$1[258] = 28;
40916
- var _b$1 = freb$1(fdeb$1, 0), fd$1 = _b$1[0];
40917
- // map of value to reverse (assuming 16 bits)
40918
- var rev$1 = new u16$1(32768);
40919
- for (var i$1 = 0; i$1 < 32768; ++i$1) {
40920
- // reverse table algorithm from SO
40921
- var x$1 = ((i$1 & 0xAAAA) >>> 1) | ((i$1 & 0x5555) << 1);
40922
- x$1 = ((x$1 & 0xCCCC) >>> 2) | ((x$1 & 0x3333) << 2);
40923
- x$1 = ((x$1 & 0xF0F0) >>> 4) | ((x$1 & 0x0F0F) << 4);
40924
- rev$1[i$1] = (((x$1 & 0xFF00) >>> 8) | ((x$1 & 0x00FF) << 8)) >>> 1;
40925
- }
40926
- // create huffman tree from u8 "map": index -> code length for code index
40927
- // mb (max bits) must be at most 15
40928
- // TODO: optimize/split up?
40929
- var hMap$1 = (function (cd, mb, r) {
40930
- var s = cd.length;
40931
- // index
40932
- var i = 0;
40933
- // u16 "map": index -> # of codes with bit length = index
40934
- var l = new u16$1(mb);
40935
- // length of cd must be 288 (total # of codes)
40936
- for (; i < s; ++i) {
40937
- if (cd[i])
40938
- ++l[cd[i] - 1];
40939
- }
40940
- // u16 "map": index -> minimum code for bit length = index
40941
- var le = new u16$1(mb);
40942
- for (i = 0; i < mb; ++i) {
40943
- le[i] = (le[i - 1] + l[i - 1]) << 1;
40944
- }
40945
- var co;
40946
- if (r) {
40947
- // u16 "map": index -> number of actual bits, symbol for code
40948
- co = new u16$1(1 << mb);
40949
- // bits to remove for reverser
40950
- var rvb = 15 - mb;
40951
- for (i = 0; i < s; ++i) {
40952
- // ignore 0 lengths
40953
- if (cd[i]) {
40954
- // num encoding both symbol and bits read
40955
- var sv = (i << 4) | cd[i];
40956
- // free bits
40957
- var r_1 = mb - cd[i];
40958
- // start value
40959
- var v = le[cd[i] - 1]++ << r_1;
40960
- // m is end value
40961
- for (var m = v | ((1 << r_1) - 1); v <= m; ++v) {
40962
- // every 16 bit value starting with the code yields the same result
40963
- co[rev$1[v] >>> rvb] = sv;
40964
- }
40965
- }
40966
- }
40967
- }
40968
- else {
40969
- co = new u16$1(s);
40970
- for (i = 0; i < s; ++i) {
40971
- if (cd[i]) {
40972
- co[i] = rev$1[le[cd[i] - 1]++] >>> (15 - cd[i]);
40973
- }
40974
- }
40975
- }
40976
- return co;
40977
- });
40978
- // fixed length tree
40979
- var flt$1 = new u8$1(288);
40980
- for (var i$1 = 0; i$1 < 144; ++i$1)
40981
- flt$1[i$1] = 8;
40982
- for (var i$1 = 144; i$1 < 256; ++i$1)
40983
- flt$1[i$1] = 9;
40984
- for (var i$1 = 256; i$1 < 280; ++i$1)
40985
- flt$1[i$1] = 7;
40986
- for (var i$1 = 280; i$1 < 288; ++i$1)
40987
- flt$1[i$1] = 8;
40988
- // fixed distance tree
40989
- var fdt$1 = new u8$1(32);
40990
- for (var i$1 = 0; i$1 < 32; ++i$1)
40991
- fdt$1[i$1] = 5;
40992
- // fixed length map
40993
- var flrm$1 = /*#__PURE__*/ hMap$1(flt$1, 9, 1);
40994
- // fixed distance map
40995
- var fdrm$1 = /*#__PURE__*/ hMap$1(fdt$1, 5, 1);
40996
- // find max of array
40997
- var max$1 = function (a) {
40998
- var m = a[0];
40999
- for (var i = 1; i < a.length; ++i) {
41000
- if (a[i] > m)
41001
- m = a[i];
41002
- }
41003
- return m;
41004
- };
41005
- // read d, starting at bit p and mask with m
41006
- var bits$1 = function (d, p, m) {
41007
- var o = (p / 8) | 0;
41008
- return ((d[o] | (d[o + 1] << 8)) >> (p & 7)) & m;
41009
- };
41010
- // read d, starting at bit p continuing for at least 16 bits
41011
- var bits16$1 = function (d, p) {
41012
- var o = (p / 8) | 0;
41013
- return ((d[o] | (d[o + 1] << 8) | (d[o + 2] << 16)) >> (p & 7));
41014
- };
41015
- // get end of byte
41016
- var shft$1 = function (p) { return ((p + 7) / 8) | 0; };
41017
- // typed array slice - allows garbage collector to free original reference,
41018
- // while being more compatible than .slice
41019
- var slc$1 = function (v, s, e) {
41020
- if (s == null || s < 0)
41021
- s = 0;
41022
- if (e == null || e > v.length)
41023
- e = v.length;
41024
- // can't use .constructor in case user-supplied
41025
- var n = new (v.BYTES_PER_ELEMENT == 2 ? u16$1 : v.BYTES_PER_ELEMENT == 4 ? u32 : u8$1)(e - s);
41026
- n.set(v.subarray(s, e));
41027
- return n;
41028
- };
41029
- // error codes
41030
- var ec$1 = [
41031
- 'unexpected EOF',
41032
- 'invalid block type',
41033
- 'invalid length/literal',
41034
- 'invalid distance',
41035
- 'stream finished',
41036
- 'no stream handler',
41037
- ,
41038
- 'no callback',
41039
- 'invalid UTF-8 data',
41040
- 'extra field too long',
41041
- 'date not in range 1980-2099',
41042
- 'filename too long',
41043
- 'stream finishing',
41044
- 'invalid zip data'
41045
- // determined by unknown compression method
41046
- ];
41047
- var err$1 = function (ind, msg, nt) {
41048
- var e = new Error(msg || ec$1[ind]);
41049
- e.code = ind;
41050
- if (Error.captureStackTrace)
41051
- Error.captureStackTrace(e, err$1);
41052
- if (!nt)
41053
- throw e;
41054
- return e;
41055
- };
41056
- // expands raw DEFLATE data
41057
- var inflt$1 = function (dat, buf, st) {
41058
- // source length
41059
- var sl = dat.length;
41060
- if (!sl || (st && st.f && !st.l))
41061
- return buf || new u8$1(0);
41062
- // have to estimate size
41063
- var noBuf = !buf || st;
41064
- // no state
41065
- var noSt = !st || st.i;
41066
- if (!st)
41067
- st = {};
41068
- // Assumes roughly 33% compression ratio average
41069
- if (!buf)
41070
- buf = new u8$1(sl * 3);
41071
- // ensure buffer can fit at least l elements
41072
- var cbuf = function (l) {
41073
- var bl = buf.length;
41074
- // need to increase size to fit
41075
- if (l > bl) {
41076
- // Double or set to necessary, whichever is greater
41077
- var nbuf = new u8$1(Math.max(bl * 2, l));
41078
- nbuf.set(buf);
41079
- buf = nbuf;
41080
- }
41081
- };
41082
- // last chunk bitpos bytes
41083
- 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;
41084
- // total bits
41085
- var tbts = sl * 8;
41086
- do {
41087
- if (!lm) {
41088
- // BFINAL - this is only 1 when last chunk is next
41089
- final = bits$1(dat, pos, 1);
41090
- // type: 0 = no compression, 1 = fixed huffman, 2 = dynamic huffman
41091
- var type = bits$1(dat, pos + 1, 3);
41092
- pos += 3;
41093
- if (!type) {
41094
- // go to end of byte boundary
41095
- var s = shft$1(pos) + 4, l = dat[s - 4] | (dat[s - 3] << 8), t = s + l;
41096
- if (t > sl) {
41097
- if (noSt)
41098
- err$1(0);
41099
- break;
41100
- }
41101
- // ensure size
41102
- if (noBuf)
41103
- cbuf(bt + l);
41104
- // Copy over uncompressed data
41105
- buf.set(dat.subarray(s, t), bt);
41106
- // Get new bitpos, update byte count
41107
- st.b = bt += l, st.p = pos = t * 8, st.f = final;
41108
- continue;
41109
- }
41110
- else if (type == 1)
41111
- lm = flrm$1, dm = fdrm$1, lbt = 9, dbt = 5;
41112
- else if (type == 2) {
41113
- // literal lengths
41114
- var hLit = bits$1(dat, pos, 31) + 257, hcLen = bits$1(dat, pos + 10, 15) + 4;
41115
- var tl = hLit + bits$1(dat, pos + 5, 31) + 1;
41116
- pos += 14;
41117
- // length+distance tree
41118
- var ldt = new u8$1(tl);
41119
- // code length tree
41120
- var clt = new u8$1(19);
41121
- for (var i = 0; i < hcLen; ++i) {
41122
- // use index map to get real code
41123
- clt[clim$1[i]] = bits$1(dat, pos + i * 3, 7);
41124
- }
41125
- pos += hcLen * 3;
41126
- // code lengths bits
41127
- var clb = max$1(clt), clbmsk = (1 << clb) - 1;
41128
- // code lengths map
41129
- var clm = hMap$1(clt, clb, 1);
41130
- for (var i = 0; i < tl;) {
41131
- var r = clm[bits$1(dat, pos, clbmsk)];
41132
- // bits read
41133
- pos += r & 15;
41134
- // symbol
41135
- var s = r >>> 4;
41136
- // code length to copy
41137
- if (s < 16) {
41138
- ldt[i++] = s;
41139
- }
41140
- else {
41141
- // copy count
41142
- var c = 0, n = 0;
41143
- if (s == 16)
41144
- n = 3 + bits$1(dat, pos, 3), pos += 2, c = ldt[i - 1];
41145
- else if (s == 17)
41146
- n = 3 + bits$1(dat, pos, 7), pos += 3;
41147
- else if (s == 18)
41148
- n = 11 + bits$1(dat, pos, 127), pos += 7;
41149
- while (n--)
41150
- ldt[i++] = c;
41151
- }
41152
- }
41153
- // length tree distance tree
41154
- var lt = ldt.subarray(0, hLit), dt = ldt.subarray(hLit);
41155
- // max length bits
41156
- lbt = max$1(lt);
41157
- // max dist bits
41158
- dbt = max$1(dt);
41159
- lm = hMap$1(lt, lbt, 1);
41160
- dm = hMap$1(dt, dbt, 1);
41161
- }
41162
- else
41163
- err$1(1);
41164
- if (pos > tbts) {
41165
- if (noSt)
41166
- err$1(0);
41167
- break;
41168
- }
41169
- }
41170
- // Make sure the buffer can hold this + the largest possible addition
41171
- // Maximum chunk size (practically, theoretically infinite) is 2^17;
41172
- if (noBuf)
41173
- cbuf(bt + 131072);
41174
- var lms = (1 << lbt) - 1, dms = (1 << dbt) - 1;
41175
- var lpos = pos;
41176
- for (;; lpos = pos) {
41177
- // bits read, code
41178
- var c = lm[bits16$1(dat, pos) & lms], sym = c >>> 4;
41179
- pos += c & 15;
41180
- if (pos > tbts) {
41181
- if (noSt)
41182
- err$1(0);
41183
- break;
41184
- }
41185
- if (!c)
41186
- err$1(2);
41187
- if (sym < 256)
41188
- buf[bt++] = sym;
41189
- else if (sym == 256) {
41190
- lpos = pos, lm = null;
41191
- break;
41192
- }
41193
- else {
41194
- var add = sym - 254;
41195
- // no extra bits needed if less
41196
- if (sym > 264) {
41197
- // index
41198
- var i = sym - 257, b = fleb$1[i];
41199
- add = bits$1(dat, pos, (1 << b) - 1) + fl$1[i];
41200
- pos += b;
41201
- }
41202
- // dist
41203
- var d = dm[bits16$1(dat, pos) & dms], dsym = d >>> 4;
41204
- if (!d)
41205
- err$1(3);
41206
- pos += d & 15;
41207
- var dt = fd$1[dsym];
41208
- if (dsym > 3) {
41209
- var b = fdeb$1[dsym];
41210
- dt += bits16$1(dat, pos) & ((1 << b) - 1), pos += b;
41211
- }
41212
- if (pos > tbts) {
41213
- if (noSt)
41214
- err$1(0);
41215
- break;
41216
- }
41217
- if (noBuf)
41218
- cbuf(bt + 131072);
41219
- var end = bt + add;
41220
- for (; bt < end; bt += 4) {
41221
- buf[bt] = buf[bt - dt];
41222
- buf[bt + 1] = buf[bt + 1 - dt];
41223
- buf[bt + 2] = buf[bt + 2 - dt];
41224
- buf[bt + 3] = buf[bt + 3 - dt];
41225
- }
41226
- bt = end;
41227
- }
41228
- }
41229
- st.l = lm, st.p = lpos, st.b = bt, st.f = final;
41230
- if (lm)
41231
- final = 1, st.m = lbt, st.d = dm, st.n = dbt;
41232
- } while (!final);
41233
- return bt == buf.length ? buf : slc$1(buf, 0, bt);
41234
- };
41235
- // empty
41236
- var et$1 = /*#__PURE__*/ new u8$1(0);
41237
- // read 2 bytes
41238
- var b2 = function (d, b) { return d[b] | (d[b + 1] << 8); };
41239
- // read 4 bytes
41240
- var b4 = function (d, b) { return (d[b] | (d[b + 1] << 8) | (d[b + 2] << 16) | (d[b + 3] << 24)) >>> 0; };
41241
- var b8 = function (d, b) { return b4(d, b) + (b4(d, b + 4) * 4294967296); };
41242
- /**
41243
- * Expands DEFLATE data with no wrapper
41244
- * @param data The data to decompress
41245
- * @param out Where to write the data. Saves memory if you know the decompressed size and provide an output buffer of that length.
41246
- * @returns The decompressed version of the data
41247
- */
41248
- function inflateSync(data, out) {
41249
- return inflt$1(data, out);
41250
- }
41251
- // text decoder
41252
- var td$1 = typeof TextDecoder != 'undefined' && /*#__PURE__*/ new TextDecoder();
41253
- // text decoder stream
41254
- var tds$1 = 0;
41255
- try {
41256
- td$1.decode(et$1, { stream: true });
41257
- tds$1 = 1;
41258
- }
41259
- catch (e) { }
41260
- // decode UTF8
41261
- var dutf8 = function (d) {
41262
- for (var r = '', i = 0;;) {
41263
- var c = d[i++];
41264
- var eb = (c > 127) + (c > 223) + (c > 239);
41265
- if (i + eb > d.length)
41266
- return [r, slc$1(d, i - 1)];
41267
- if (!eb)
41268
- r += String.fromCharCode(c);
41269
- else if (eb == 3) {
41270
- c = ((c & 15) << 18 | (d[i++] & 63) << 12 | (d[i++] & 63) << 6 | (d[i++] & 63)) - 65536,
41271
- r += String.fromCharCode(55296 | (c >> 10), 56320 | (c & 1023));
41272
- }
41273
- else if (eb & 1)
41274
- r += String.fromCharCode((c & 31) << 6 | (d[i++] & 63));
41275
- else
41276
- r += String.fromCharCode((c & 15) << 12 | (d[i++] & 63) << 6 | (d[i++] & 63));
41277
- }
41278
- };
41279
- /**
41280
- * Converts a Uint8Array to a string
41281
- * @param dat The data to decode to string
41282
- * @param latin1 Whether or not to interpret the data as Latin-1. This should
41283
- * not need to be true unless encoding to binary string.
41284
- * @returns The original UTF-8/Latin-1 string
41285
- */
41286
- function strFromU8(dat, latin1) {
41287
- if (latin1) {
41288
- var r = '';
41289
- for (var i = 0; i < dat.length; i += 16384)
41290
- r += String.fromCharCode.apply(null, dat.subarray(i, i + 16384));
41291
- return r;
41292
- }
41293
- else if (td$1)
41294
- return td$1.decode(dat);
41295
- else {
41296
- var _a = dutf8(dat), out = _a[0], ext = _a[1];
41297
- if (ext.length)
41298
- err$1(8);
41299
- return out;
41300
- }
41301
- }
41302
- // skip local zip header
41303
- var slzh = function (d, b) { return b + 30 + b2(d, b + 26) + b2(d, b + 28); };
41304
- // read zip header
41305
- var zh = function (d, b, z) {
41306
- 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);
41307
- 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];
41308
- return [b2(d, b + 10), sc, su, fn, es + b2(d, b + 30) + b2(d, b + 32), off];
41309
- };
41310
- // read zip64 extra field
41311
- var z64e = function (d, b) {
41312
- for (; b2(d, b) != 1; b += 4 + b2(d, b + 2))
41313
- ;
41314
- return [b8(d, b + 12), b8(d, b + 4), b8(d, b + 20)];
41315
- };
41316
- /**
41317
- * Synchronously decompresses a ZIP archive. Prefer using `unzip` for better
41318
- * performance with more than one file.
41319
- * @param data The raw compressed ZIP file
41320
- * @param opts The ZIP extraction options
41321
- * @returns The decompressed files
41322
- */
41323
- function unzipSync(data, opts) {
41324
- var files = {};
41325
- var e = data.length - 22;
41326
- for (; b4(data, e) != 0x6054B50; --e) {
41327
- if (!e || data.length - e > 65558)
41328
- err$1(13);
41329
- }
41330
- var c = b2(data, e + 8);
41331
- if (!c)
41332
- return {};
41333
- var o = b4(data, e + 16);
41334
- var z = o == 4294967295 || c == 65535;
41335
- if (z) {
41336
- var ze = b4(data, e - 12);
41337
- z = b4(data, ze) == 0x6064B50;
41338
- if (z) {
41339
- c = b4(data, ze + 32);
41340
- o = b4(data, ze + 48);
41341
- }
41342
- }
41343
- var fltr = opts && opts.filter;
41344
- for (var i = 0; i < c; ++i) {
41345
- 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);
41346
- o = no;
41347
- if (!fltr || fltr({
41348
- name: fn,
41349
- size: sc,
41350
- originalSize: su,
41351
- compression: c_2
41352
- })) {
41353
- if (!c_2)
41354
- files[fn] = slc$1(data, b, b + sc);
41355
- else if (c_2 == 8)
41356
- files[fn] = inflateSync(data.subarray(b, b + sc), new u8$1(su));
41357
- else
41358
- err$1(14, 'unknown compression type ' + c_2);
41359
- }
41360
- }
41361
- return files;
41362
- }
41363
-
41364
- 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
41365
-
41366
- 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
40934
+ 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
41367
40935
 
41368
40936
  let planeWidth = 80;
41369
40937
  let planeHeight = 80;
41370
- function createTexture2D_Zip(url, scene) {
41371
- new FileLoader()
41372
- .setResponseType("arraybuffer")
41373
- .load(url, function (data) {
41374
- const zip = unzipSync(new Uint8Array(data));
41375
- const array = new Uint8Array(zip["head256x256x109"].buffer);
41376
- const texture = new DataArrayTexture(array, 256, 256, 109);
41377
- texture.format = RedFormat;
41378
- texture.needsUpdate = true;
41379
- const material = new ShaderMaterial({
41380
- uniforms: {
41381
- diffuse: { value: texture },
41382
- depth: { value: 1 },
41383
- size: { value: new Vector2(planeWidth, planeHeight) },
41384
- },
41385
- vertexShader: vert_2d,
41386
- fragmentShader: frag_2d,
41387
- glslVersion: GLSL3,
41388
- side: DoubleSide,
41389
- });
41390
- const geometry = new PlaneGeometry(planeWidth, planeHeight);
41391
- const mesh = new Mesh(geometry, material);
41392
- mesh.name = "texture2d_mesh_zip";
41393
- scene.add(mesh);
41394
- });
40938
+ // Build a quad from the 4 world-space image-plane corners (preserves real pose).
40939
+ function buildAlignedQuad(c) {
40940
+ const g = new BufferGeometry();
40941
+ // two triangles: tl,bl,tr / tr,bl,br
40942
+ g.setAttribute("position", new BufferAttribute(new Float32Array([...c.tl, ...c.bl, ...c.tr, ...c.tr, ...c.bl, ...c.br]), 3));
40943
+ // vertex order is tl, bl, tr, tr, bl, br. The fragment shader flips v (vUv.y = 1-uv.y)
40944
+ // and DataArrayTexture has no flipY, so the image-top (data row 0) must map to the
40945
+ // top world corner: tl/tr get uv.y = 1.
40946
+ g.setAttribute("uv", new BufferAttribute(new Float32Array([0, 1, 0, 0, 1, 1, 1, 1, 0, 0, 1, 0]), 2));
40947
+ g.computeVertexNormals();
40948
+ return g;
41395
40949
  }
41396
40950
  function createTexture2D_NRRD(data, width, height, depth, callback) {
41397
40951
  const texture = new DataArrayTexture(data, width, height, depth);
@@ -41401,7 +40955,7 @@ function createTexture2D_NRRD(data, width, height, depth, callback) {
41401
40955
  uniforms: {
41402
40956
  diffuse: { value: texture },
41403
40957
  depth: { value: 1 },
41404
- size: { value: new Vector2(planeWidth, planeHeight) },
40958
+ uOpacity: { value: 1 },
41405
40959
  },
41406
40960
  vertexShader: vert_2d,
41407
40961
  fragmentShader: frag_2d,
@@ -41413,13 +40967,9 @@ function createTexture2D_NRRD(data, width, height, depth, callback) {
41413
40967
  mesh.name = "texture2d_mesh_zip";
41414
40968
  callback(mesh);
41415
40969
  }
41416
- function createTexture2D_Array(copperVolume, depth, scene, gui) {
40970
+ function createTexture2D_Array(copperVolume, depth, scene, gui, aligned = false) {
41417
40971
  planeWidth = copperVolume.width / 2;
41418
40972
  planeHeight = copperVolume.height / 2;
41419
- ({
41420
- windowWidth: copperVolume.windowWidth,
41421
- windowCenter: copperVolume.windowCenter,
41422
- });
41423
40973
  const texture = new DataArrayTexture(copperVolume.uint8, copperVolume.width, copperVolume.height, depth);
41424
40974
  texture.format = RedFormat;
41425
40975
  texture.needsUpdate = true;
@@ -41446,18 +40996,22 @@ function createTexture2D_Array(copperVolume, depth, scene, gui) {
41446
40996
  const material = new ShaderMaterial({
41447
40997
  uniforms: {
41448
40998
  diffuse: { value: texture },
41449
- depth: { value: 1 },
41450
- size: { value: new Vector2(planeWidth, planeHeight) },
40999
+ depth: { value: 0 },
41000
+ uOpacity: { value: 1 },
41451
41001
  },
41452
41002
  vertexShader: vert_2d,
41453
41003
  fragmentShader: frag_2d,
41454
41004
  glslVersion: GLSL3,
41455
41005
  side: DoubleSide,
41456
41006
  });
41457
- const geometry = new PlaneGeometry(planeWidth, planeHeight);
41007
+ // Aligned world-space quad only when explicitly requested and geometry tags exist;
41008
+ // otherwise the legacy centred plane (keeps existing loadDicom behaviour unchanged).
41009
+ const geometry = aligned && copperVolume.corners
41010
+ ? buildAlignedQuad(copperVolume.corners)
41011
+ : new PlaneGeometry(planeWidth, planeHeight);
41458
41012
  const mesh = new Mesh(geometry, material);
41459
- scene.add(mesh);
41460
41013
  mesh.name = "texture2d_mesh_array";
41014
+ scene.add(mesh);
41461
41015
  function updateTexture(copperVolumeUp) {
41462
41016
  if (!!copperVolumeUp) {
41463
41017
  let voiLUT;
@@ -41468,7 +41022,15 @@ function createTexture2D_Array(copperVolume, depth, scene, gui) {
41468
41022
  texture.needsUpdate = true;
41469
41023
  }
41470
41024
  }
41471
- return { mesh, copperVolume, updateTexture };
41025
+ function setFrame(i) {
41026
+ material.uniforms.depth.value = i;
41027
+ }
41028
+ function setWindow(center, width) {
41029
+ copperVolume.windowCenter = center;
41030
+ copperVolume.windowWidth = width;
41031
+ updateTexture(copperVolume);
41032
+ }
41033
+ return { mesh, copperVolume, updateTexture, setFrame, setWindow };
41472
41034
  }
41473
41035
 
41474
41036
  /*!
@@ -50524,6 +50086,7 @@ var Stats$1 = Stats;
50524
50086
  class baseRenderer {
50525
50087
  constructor(container, options) {
50526
50088
  var _a, _b, _c, _d, _e;
50089
+ this.running = true;
50527
50090
  this.visualCtrls = [];
50528
50091
  this.container = container;
50529
50092
  this.options = options;
@@ -50624,6 +50187,25 @@ class baseRenderer {
50624
50187
  setClearColor(clearColor = 0x000000, alpha = 0) {
50625
50188
  this.renderer.setClearColor(clearColor, alpha);
50626
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
+ }
50627
50209
  addGui() {
50628
50210
  var _a, _b, _c;
50629
50211
  const gui = (this.gui = new GUI$1({
@@ -59300,6 +58882,7 @@ function onTouchEnd( event ) {
59300
58882
  }
59301
58883
 
59302
58884
  class copperScene extends baseScene {
58885
+ // rayster pick
59303
58886
  constructor(container, renderer, opt) {
59304
58887
  super(container, renderer, opt);
59305
58888
  this.clock = new Clock();
@@ -59307,9 +58890,6 @@ class copperScene extends baseScene {
59307
58890
  this.mixer = null;
59308
58891
  this.playRate = 1.0;
59309
58892
  this.modelReady = false;
59310
- // rayster pick
59311
- // texture2d
59312
- this.texture2dMesh = null;
59313
58893
  if ((opt === null || opt === void 0 ? void 0 : opt.controls) === "trackball") {
59314
58894
  this.controls = new TrackballControls(this.camera, this.renderer.domElement);
59315
58895
  }
@@ -59515,38 +59095,177 @@ class copperScene extends baseScene {
59515
59095
  this.exportContent.animations.push(clip);
59516
59096
  };
59517
59097
  }
59518
- // texture2d
59519
- texture2d(url) {
59520
- createTexture2D_Zip(url, this.scene);
59521
- const textureInterval = setInterval(() => {
59522
- var _a;
59523
- this.scene.children.forEach((child) => {
59524
- if (child.isMesh) {
59525
- if (child.name === "texture2d_mesh_zip") {
59526
- this.texture2dMesh = child;
59527
- const render_texture2d = () => {
59528
- if (this.texture2dMesh) {
59529
- let value = this.texture2dMesh.material.uniforms["depth"].value;
59530
- value += this.depthStep;
59531
- if (value > 109.0 || value < 0.0) {
59532
- if (value > 1.0)
59533
- value = 109.0 * 2.0 - value;
59534
- if (value < 0.0)
59535
- value = -value;
59536
- this.depthStep = -this.depthStep;
59537
- }
59538
- this.texture2dMesh.material.uniforms["depth"].value =
59539
- value;
59540
- }
59541
- };
59542
- this.addPreRenderCallbackFunction(render_texture2d);
59543
- }
59544
- }
59098
+ /**
59099
+ * Load an aligned 4D scene: a cine MRI (one slice, N cardiac phases) rendered as a
59100
+ * world-placed plane, plus 0..N deforming surface sequences (e.g. LV endo/epi). MRI
59101
+ * and surfaces stay in their shared patient coordinate frame (no center/scale), and
59102
+ * advance together off ONE shared frame clock. Returns a controller (play/pause/...).
59103
+ */
59104
+ loadAligned4D(opts, callback) {
59105
+ var _a;
59106
+ const dicomDepth = opts.dicomUrls.length;
59107
+ const loadDicomStack = () => new Promise((resolve) => {
59108
+ const volumes = [];
59109
+ opts.dicomUrls.forEach((url) => {
59110
+ copperDicomLoader(url, (volume) => {
59111
+ volumes.push(volume);
59112
+ if (volumes.length !== dicomDepth)
59113
+ return;
59114
+ // order frames by cardiac phase (TriggerTime / SliceLocation)
59115
+ volumes.sort((a, b) => a.order - b.order);
59116
+ const frameSize = volumes[0].width * volumes[0].height;
59117
+ const uint8 = new Uint8ClampedArray(frameSize * dicomDepth);
59118
+ const uint16 = new Uint16Array(uint8.length);
59119
+ volumes.forEach((v, index) => {
59120
+ uint8.set(v.uint8, index * frameSize);
59121
+ uint16.set(v.uint16, index * frameSize);
59122
+ });
59123
+ const stacked = Object.assign(Object.assign({}, volumes[0]), { uint8,
59124
+ uint16 });
59125
+ resolve(stacked);
59126
+ });
59127
+ });
59128
+ });
59129
+ const loadSurfaceSequence = (urls) => new Promise((resolve) => {
59130
+ const loader = new VTKLoader();
59131
+ const geometries = new Array(urls.length);
59132
+ let done = 0;
59133
+ urls.forEach((url, index) => {
59134
+ loader.load(url, (geometry) => {
59135
+ // keep world coordinates — no center()/scale()
59136
+ geometry.computeVertexNormals();
59137
+ geometries[index] = geometry;
59138
+ if (++done === urls.length)
59139
+ resolve(geometries);
59140
+ });
59545
59141
  });
59546
- if (((_a = this.texture2dMesh) === null || _a === void 0 ? void 0 : _a.name) === "texture2d_mesh_zip") {
59547
- clearInterval(textureInterval);
59142
+ });
59143
+ const surfaceDefs = (_a = opts.surfaces) !== null && _a !== void 0 ? _a : [];
59144
+ Promise.all([
59145
+ loadDicomStack(),
59146
+ ...surfaceDefs.map((s) => loadSurfaceSequence(s.urls)),
59147
+ ]).then(([stacked, ...surfaceGeoms]) => {
59148
+ var _a;
59149
+ if (opts.window) {
59150
+ stacked.windowCenter = opts.window.center;
59151
+ stacked.windowWidth = opts.window.width;
59548
59152
  }
59549
- }, 500);
59153
+ const tex = createTexture2D_Array(stacked, dicomDepth, this.scene, undefined, true);
59154
+ tex.setFrame(0);
59155
+ if (opts.window)
59156
+ tex.setWindow(opts.window.center, opts.window.width);
59157
+ const surfaceMeshes = {};
59158
+ const seqs = [];
59159
+ surfaceDefs.forEach((def, i) => {
59160
+ var _a;
59161
+ const geometries = surfaceGeoms[i];
59162
+ const { vtkmaterial } = copperMultipleVtk(def.opts);
59163
+ const mesh = new Mesh(geometries[0], vtkmaterial);
59164
+ mesh.name = def.name;
59165
+ this.scene.add(mesh);
59166
+ surfaceMeshes[def.name] = mesh;
59167
+ seqs.push({ mesh, geometries, offset: (_a = def.offset) !== null && _a !== void 0 ? _a : 0 });
59168
+ });
59169
+ const frameCount = dicomDepth;
59170
+ // Default one cardiac cycle ≈ 1012ms (32 phases × ~31.6ms) unless overridden.
59171
+ const cycleMs = (_a = opts.cycleMs) !== null && _a !== void 0 ? _a : 1012;
59172
+ let frameIndex = 0;
59173
+ let speed = 1;
59174
+ let playing = true;
59175
+ let disposed = false;
59176
+ let lastStep = performance.now();
59177
+ const dtBase = cycleMs / frameCount;
59178
+ const applyFrame = () => {
59179
+ tex.setFrame(frameIndex);
59180
+ for (const s of seqs) {
59181
+ const i = (frameIndex + s.offset + s.geometries.length) % s.geometries.length;
59182
+ s.mesh.geometry = s.geometries[i];
59183
+ }
59184
+ };
59185
+ const tick = () => {
59186
+ if (disposed || !playing)
59187
+ return;
59188
+ const now = performance.now();
59189
+ if (now - lastStep >= dtBase / speed) {
59190
+ lastStep = now;
59191
+ frameIndex = (frameIndex + 1) % frameCount;
59192
+ applyFrame();
59193
+ }
59194
+ };
59195
+ this.addPreRenderCallbackFunction(tick);
59196
+ const clockId = tick.id;
59197
+ const ctrl = {
59198
+ plane: tex.mesh,
59199
+ surfaceMeshes,
59200
+ frameCount,
59201
+ play: () => {
59202
+ playing = true;
59203
+ lastStep = performance.now();
59204
+ },
59205
+ pause: () => {
59206
+ playing = false;
59207
+ },
59208
+ toggle: () => {
59209
+ playing = !playing;
59210
+ lastStep = performance.now();
59211
+ },
59212
+ setSpeed: (x) => {
59213
+ speed = Math.max(0.01, x);
59214
+ },
59215
+ setFrame: (i) => {
59216
+ frameIndex = ((i % frameCount) + frameCount) % frameCount;
59217
+ applyFrame();
59218
+ },
59219
+ setFrameOffset: (name, n) => {
59220
+ const s = seqs.find((q) => q.mesh.name === name);
59221
+ if (s) {
59222
+ s.offset = n;
59223
+ applyFrame();
59224
+ }
59225
+ },
59226
+ setWindow: (center, width) => {
59227
+ tex.setWindow(center, width);
59228
+ },
59229
+ setPlaneOpacity: (v) => {
59230
+ const m = tex.mesh.material;
59231
+ m.transparent = v < 1;
59232
+ m.uniforms.uOpacity.value = v;
59233
+ m.needsUpdate = true;
59234
+ },
59235
+ setSurfaceOpacity: (name, v) => {
59236
+ const mesh = surfaceMeshes[name];
59237
+ if (!mesh)
59238
+ return;
59239
+ const m = mesh.material;
59240
+ m.transparent = v < 1;
59241
+ m.opacity = v;
59242
+ m.needsUpdate = true;
59243
+ },
59244
+ setSurfaceVisible: (name, visible) => {
59245
+ const mesh = surfaceMeshes[name];
59246
+ if (mesh)
59247
+ mesh.visible = visible;
59248
+ },
59249
+ dispose: () => {
59250
+ var _a, _b;
59251
+ disposed = true;
59252
+ this.removePreRenderCallbackFunction(clockId);
59253
+ // plane
59254
+ this.scene.remove(tex.mesh);
59255
+ tex.mesh.geometry.dispose();
59256
+ const pm = tex.mesh.material;
59257
+ (_b = (_a = pm.uniforms.diffuse.value) === null || _a === void 0 ? void 0 : _a.dispose) === null || _b === void 0 ? void 0 : _b.call(_a);
59258
+ pm.dispose();
59259
+ // surfaces
59260
+ for (const s of seqs) {
59261
+ this.scene.remove(s.mesh);
59262
+ s.geometries.forEach((g) => g.dispose());
59263
+ s.mesh.material.dispose();
59264
+ }
59265
+ },
59266
+ };
59267
+ callback && callback(ctrl);
59268
+ });
59550
59269
  }
59551
59270
  getPlayRate() {
59552
59271
  return this.playRate;
@@ -59654,6 +59373,8 @@ class copperRenderer extends baseRenderer {
59654
59373
  this.preRenderCallbackFunctions = [];
59655
59374
  this.animate = (time) => {
59656
59375
  var _a, _b, _c;
59376
+ if (!this.running)
59377
+ return;
59657
59378
  switch ((_a = this.options) === null || _a === void 0 ? void 0 : _a.fpsMode) {
59658
59379
  case "1":
59659
59380
  // fpsControl one: 30fps
@@ -60204,6 +59925,7 @@ class copperMSceneRenderer {
60204
59925
  alpha: true,
60205
59926
  antialias: true,
60206
59927
  });
59928
+ this.running = true;
60207
59929
  this.renderSceneInfo = (sceneInfo) => {
60208
59930
  const elem = sceneInfo.container;
60209
59931
  // get the viewpoint relative position of this element
@@ -60242,6 +59964,8 @@ class copperMSceneRenderer {
60242
59964
  }
60243
59965
  };
60244
59966
  this.animate = () => {
59967
+ if (!this.running)
59968
+ return;
60245
59969
  const clearColor = new Color$1("#000");
60246
59970
  this.renderer.setScissorTest(false);
60247
59971
  this.renderer.setClearColor(clearColor, 0);
@@ -60307,6 +60031,25 @@ class copperMSceneRenderer {
60307
60031
  }, undefined, reject);
60308
60032
  });
60309
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
+ }
60310
60053
  }
60311
60054
 
60312
60055
  /******************************************************************************
@@ -70726,6 +70469,36 @@ const CHANNEL_HEX_COLORS = {
70726
70469
  7: '#f97316',
70727
70470
  8: '#8b5cf6', // Violet
70728
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
+ };
70729
70502
  // ── Color Conversion Utilities ──────────────────────────────────────────
70730
70503
  /**
70731
70504
  * Convert an RGBAColor to a hex string (no alpha), e.g. '#ff0000'.
@@ -73874,6 +73647,8 @@ class EventRouter {
73874
73647
  constructor(config) {
73875
73648
  // === State ===
73876
73649
  this.mode = 'idle';
73650
+ /** Mode to restore after a right-drag pan ends (so pan doesn't drop aiAssist). */
73651
+ this.modeBeforePan = 'idle';
73877
73652
  this.guiTool = 'pencil';
73878
73653
  this.state = {
73879
73654
  shiftHeld: false,
@@ -73999,7 +73774,20 @@ class EventRouter {
73999
73774
  * Set the GUI tool (from UI buttons).
74000
73775
  */
74001
73776
  setGuiTool(tool) {
73777
+ const prevTool = this.guiTool;
74002
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
+ }
74003
73791
  // When entering any sphere-family tool, keep crosshair if active, otherwise idle
74004
73792
  if (SPHERE_TOOLS.has(tool)) {
74005
73793
  if (!this.state.crosshairEnabled) {
@@ -74013,8 +73801,9 @@ class EventRouter {
74013
73801
  * Blocked when draw or contrast mode is active, or left button is held (mutual exclusion).
74014
73802
  */
74015
73803
  toggleCrosshair() {
74016
- // Allow crosshair in drawing tools and all sphere-family tools (sphere / sphereBrush / sphereEraser)
74017
- 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')
74018
73807
  return;
74019
73808
  // Block crosshair activation during draw, contrast, or while left button held.
74020
73809
  // The leftButtonDown guard also enforces "once a sphere preview is on screen,
@@ -74022,7 +73811,15 @@ class EventRouter {
74022
73811
  if (this.state.shiftHeld || this.state.leftButtonDown || this.mode === 'draw' || this.mode === 'contrast')
74023
73812
  return;
74024
73813
  this.state.crosshairEnabled = !this.state.crosshairEnabled;
74025
- 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
+ }
74026
73823
  }
74027
73824
  /**
74028
73825
  * Check if crosshair mode is enabled.
@@ -74158,8 +73955,8 @@ class EventRouter {
74158
73955
  }
74159
73956
  }
74160
73957
  if (this.contrastEnabled && this.keyboardSettings.contrast.includes(ev.key)) {
74161
- // Block contrast state when crosshair, draw, or any sphere-family tool is active (mutual exclusion)
74162
- 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'
74163
73960
  && !SPHERE_TOOLS.has(this.guiTool)) {
74164
73961
  this.state.ctrlHeld = true;
74165
73962
  }
@@ -74196,6 +73993,10 @@ class EventRouter {
74196
73993
  }
74197
73994
  else if (ev.button === 2) {
74198
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;
74199
74000
  this.setMode('pan');
74200
74001
  }
74201
74002
  // Route to external handler
@@ -74216,7 +74017,10 @@ class EventRouter {
74216
74017
  else if (ev.button === 2) {
74217
74018
  this.state.rightButtonDown = false;
74218
74019
  if (this.mode === 'pan') {
74219
- 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';
74220
74024
  }
74221
74025
  }
74222
74026
  // Route to external handler
@@ -76139,6 +75943,474 @@ class SphereBrushTool extends BaseTool {
76139
75943
  }
76140
75944
  }
76141
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
+
76142
76414
  /**
76143
76415
  * DrawToolCore — Tool orchestration and event routing.
76144
76416
  *
@@ -76259,6 +76531,10 @@ class DrawToolCore {
76259
76531
  setEmptyCanvasSize: (axis) => this.setEmptyCanvasSize(axis),
76260
76532
  reloadMasksFromVolume: () => this.reloadMasksFromVolume(),
76261
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
+ });
76262
76538
  }
76263
76539
  initDrawToolCore() {
76264
76540
  // Initialize EventRouter for centralized event handling
@@ -76365,6 +76641,8 @@ class DrawToolCore {
76365
76641
  // Block contrast toggle during crosshair, draw, or sphere (mutual exclusion)
76366
76642
  if (this.eventRouter.isCrosshairEnabled() || this.eventRouter.getMode() === 'draw')
76367
76643
  return;
76644
+ if (this.eventRouter.getMode() === 'aiAssist')
76645
+ return;
76368
76646
  if (this.state.gui_states.mode.sphere)
76369
76647
  return;
76370
76648
  // Toggle contrast mode manually since it's on keyup
@@ -76378,6 +76656,10 @@ class DrawToolCore {
76378
76656
  });
76379
76657
  // Register pointer handlers with EventRouter
76380
76658
  this.eventRouter.setPointerMoveHandler((e) => {
76659
+ if (this.eventRouter.getMode() === 'aiAssist') {
76660
+ this.aiAssistTool.onPointerMove(e);
76661
+ return;
76662
+ }
76381
76663
  if (this.drawingTool.isActive || this.panTool.isActive) {
76382
76664
  this.drawingPrameters.handleOnDrawingMouseMove(e);
76383
76665
  }
@@ -76392,6 +76674,14 @@ class DrawToolCore {
76392
76674
  }
76393
76675
  });
76394
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
+ }
76395
76685
  // Restore Scroll:Zoom if we swapped to Scroll:Slice on drag-start.
76396
76686
  // Must run outside the drawing-tool gate below: slice-drag doesn't
76397
76687
  // flip any of those flags, so the gate would skip restoration.
@@ -76551,7 +76841,10 @@ class DrawToolCore {
76551
76841
  this.activeWheelMode = 'none';
76552
76842
  }
76553
76843
  if (e.button === 0) {
76554
- if (this.eventRouter.getMode() === 'draw') {
76844
+ if (this.eventRouter.getMode() === 'aiAssist') {
76845
+ this.aiAssistTool.onPointerDown(e);
76846
+ }
76847
+ else if (this.eventRouter.getMode() === 'draw') {
76555
76848
  this.drawingTool.onPointerDown(e);
76556
76849
  }
76557
76850
  else if (this.eventRouter.isCrosshairEnabled()) {
@@ -76666,6 +76959,12 @@ class DrawToolCore {
76666
76959
  || this.state.gui_states.mode.sphereEraser) {
76667
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);
76668
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
+ }
76669
76968
  }
76670
76969
  else {
76671
76970
  this.redrawDisplayCanvas();
@@ -78270,6 +78569,13 @@ class NrrdTools {
78270
78569
  this._calculatorActive = false;
78271
78570
  /** Stored closure callbacks from gui.ts setupGui() */
78272
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;
78273
78579
  this.container = container;
78274
78580
  // Create shared state
78275
78581
  const mainAreaContainer = document.createElement("div");
@@ -78437,6 +78743,13 @@ class NrrdTools {
78437
78743
  var _a, _b, _c;
78438
78744
  if (!this.guiCallbacks)
78439
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
+ }
78440
78753
  const prevSphere = this.state.gui_states.mode.sphere;
78441
78754
  const prevSphereBrush = this.state.gui_states.mode.sphereBrush;
78442
78755
  const prevSphereEraser = this.state.gui_states.mode.sphereEraser;
@@ -79085,6 +79398,111 @@ class NrrdTools {
79085
79398
  this.resetLayerCanvas();
79086
79399
  this.reloadMasksFromVolume();
79087
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
+ }
79088
79506
  // ═══════════════════════════════════════════════════════════════════════════
79089
79507
  // 11. Clear / Reset
79090
79508
  // ═══════════════════════════════════════════════════════════════════════════
@@ -79448,7 +79866,7 @@ function evaluateElement(element, weights) {
79448
79866
  }
79449
79867
 
79450
79868
  // import * as kiwrious from "copper3d_plugin_heart_k";
79451
- const REVISION = "v3.4.8-beta";
79869
+ const REVISION = "v3.5.0-beta";
79452
79870
  console.log(`%cCopper3D Visualisation %cBeta:${REVISION}`, "padding: 3px;color:white; background:#023047", "padding: 3px;color:white; background:#f50a25");
79453
79871
 
79454
- 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 };