copper3d 2.2.3 → 3.0.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 (111) hide show
  1. package/README.md +1056 -5
  2. package/dist/Scene/copperScene.d.ts +3 -0
  3. package/dist/Scene/copperScene.js +13 -0
  4. package/dist/Scene/copperScene.js.map +1 -1
  5. package/dist/Utils/segmentation/CommToolsData.d.ts +109 -6
  6. package/dist/Utils/segmentation/CommToolsData.js +314 -50
  7. package/dist/Utils/segmentation/CommToolsData.js.map +1 -1
  8. package/dist/Utils/segmentation/DragOperator.d.ts +13 -7
  9. package/dist/Utils/segmentation/DragOperator.js +51 -165
  10. package/dist/Utils/segmentation/DragOperator.js.map +1 -1
  11. package/dist/Utils/segmentation/DrawToolCore.d.ts +57 -76
  12. package/dist/Utils/segmentation/DrawToolCore.js +368 -878
  13. package/dist/Utils/segmentation/DrawToolCore.js.map +1 -1
  14. package/dist/Utils/segmentation/NrrdTools.d.ts +277 -15
  15. package/dist/Utils/segmentation/NrrdTools.js +594 -232
  16. package/dist/Utils/segmentation/NrrdTools.js.map +1 -1
  17. package/dist/Utils/segmentation/core/MaskVolume.d.ts +477 -0
  18. package/dist/Utils/segmentation/core/MaskVolume.js +1238 -0
  19. package/dist/Utils/segmentation/core/MaskVolume.js.map +1 -0
  20. package/dist/Utils/segmentation/core/MigrationUtils.d.ts +97 -0
  21. package/dist/Utils/segmentation/core/MigrationUtils.js +163 -0
  22. package/dist/Utils/segmentation/core/MigrationUtils.js.map +1 -0
  23. package/dist/Utils/segmentation/core/UndoManager.d.ts +47 -0
  24. package/dist/Utils/segmentation/core/UndoManager.js +86 -0
  25. package/dist/Utils/segmentation/core/UndoManager.js.map +1 -0
  26. package/dist/Utils/segmentation/core/index.d.ts +76 -0
  27. package/dist/Utils/segmentation/core/index.js +75 -0
  28. package/dist/Utils/segmentation/core/index.js.map +1 -0
  29. package/dist/Utils/segmentation/core/types.d.ts +106 -0
  30. package/dist/Utils/segmentation/core/types.js +98 -0
  31. package/dist/Utils/segmentation/core/types.js.map +1 -0
  32. package/dist/Utils/segmentation/coreTools/coreType.d.ts +55 -36
  33. package/dist/Utils/segmentation/coreTools/coreType.js.map +1 -1
  34. package/dist/Utils/segmentation/coreTools/gui.d.ts +6 -16
  35. package/dist/Utils/segmentation/coreTools/gui.js +23 -27
  36. package/dist/Utils/segmentation/coreTools/gui.js.map +1 -1
  37. package/dist/Utils/segmentation/eventRouter/EventRouter.d.ts +144 -0
  38. package/dist/Utils/segmentation/eventRouter/EventRouter.js +369 -0
  39. package/dist/Utils/segmentation/eventRouter/EventRouter.js.map +1 -0
  40. package/dist/Utils/segmentation/eventRouter/index.d.ts +7 -0
  41. package/dist/Utils/segmentation/eventRouter/index.js +7 -0
  42. package/dist/Utils/segmentation/eventRouter/index.js.map +1 -0
  43. package/dist/Utils/segmentation/eventRouter/types.d.ts +70 -0
  44. package/dist/Utils/segmentation/eventRouter/types.js +7 -0
  45. package/dist/Utils/segmentation/eventRouter/types.js.map +1 -0
  46. package/dist/Utils/segmentation/tools/BaseTool.d.ts +25 -0
  47. package/dist/Utils/segmentation/tools/BaseTool.js +18 -0
  48. package/dist/Utils/segmentation/tools/BaseTool.js.map +1 -0
  49. package/dist/Utils/segmentation/tools/ContrastTool.d.ts +26 -0
  50. package/dist/Utils/segmentation/tools/ContrastTool.js +118 -0
  51. package/dist/Utils/segmentation/tools/ContrastTool.js.map +1 -0
  52. package/dist/Utils/segmentation/tools/CrosshairTool.d.ts +30 -0
  53. package/dist/Utils/segmentation/tools/CrosshairTool.js +157 -0
  54. package/dist/Utils/segmentation/tools/CrosshairTool.js.map +1 -0
  55. package/dist/Utils/segmentation/tools/DragSliceTool.d.ts +44 -0
  56. package/dist/Utils/segmentation/tools/DragSliceTool.js +170 -0
  57. package/dist/Utils/segmentation/tools/DragSliceTool.js.map +1 -0
  58. package/dist/Utils/segmentation/tools/EraserTool.d.ts +16 -0
  59. package/dist/Utils/segmentation/tools/EraserTool.js +92 -0
  60. package/dist/Utils/segmentation/tools/EraserTool.js.map +1 -0
  61. package/dist/Utils/segmentation/tools/ImageStoreHelper.d.ts +65 -0
  62. package/dist/Utils/segmentation/tools/ImageStoreHelper.js +174 -0
  63. package/dist/Utils/segmentation/tools/ImageStoreHelper.js.map +1 -0
  64. package/dist/Utils/segmentation/tools/SphereTool.d.ts +38 -0
  65. package/dist/Utils/segmentation/tools/SphereTool.js +193 -0
  66. package/dist/Utils/segmentation/tools/SphereTool.js.map +1 -0
  67. package/dist/Utils/segmentation/tools/ZoomTool.d.ts +20 -0
  68. package/dist/Utils/segmentation/tools/ZoomTool.js +59 -0
  69. package/dist/Utils/segmentation/tools/ZoomTool.js.map +1 -0
  70. package/dist/Utils/segmentation/tools/index.d.ts +22 -0
  71. package/dist/Utils/segmentation/tools/index.js +23 -0
  72. package/dist/Utils/segmentation/tools/index.js.map +1 -0
  73. package/dist/bundle.esm.js +4740 -1979
  74. package/dist/bundle.umd.js +4743 -1978
  75. package/dist/index.d.ts +5 -3
  76. package/dist/index.js +4 -2
  77. package/dist/index.js.map +1 -1
  78. package/dist/types/Renderer/copperRenderer.d.ts +1 -1
  79. package/dist/types/Renderer/copperRendererOnDemond.d.ts +1 -1
  80. package/dist/types/Scene/copperScene.d.ts +3 -0
  81. package/dist/types/Utils/segmentation/CommToolsData.d.ts +109 -6
  82. package/dist/types/Utils/segmentation/DragOperator.d.ts +13 -7
  83. package/dist/types/Utils/segmentation/DrawToolCore.d.ts +57 -76
  84. package/dist/types/Utils/segmentation/NrrdTools.d.ts +277 -15
  85. package/dist/types/Utils/segmentation/core/MaskVolume.d.ts +477 -0
  86. package/dist/types/Utils/segmentation/core/MigrationUtils.d.ts +97 -0
  87. package/dist/types/Utils/segmentation/core/UndoManager.d.ts +47 -0
  88. package/dist/types/Utils/segmentation/core/index.d.ts +76 -0
  89. package/dist/types/Utils/segmentation/core/types.d.ts +106 -0
  90. package/dist/types/Utils/segmentation/coreTools/coreType.d.ts +55 -36
  91. package/dist/types/Utils/segmentation/coreTools/gui.d.ts +6 -16
  92. package/dist/types/Utils/segmentation/eventRouter/EventRouter.d.ts +144 -0
  93. package/dist/types/Utils/segmentation/eventRouter/index.d.ts +7 -0
  94. package/dist/types/Utils/segmentation/eventRouter/types.d.ts +70 -0
  95. package/dist/types/Utils/segmentation/tools/BaseTool.d.ts +25 -0
  96. package/dist/types/Utils/segmentation/tools/ContrastTool.d.ts +26 -0
  97. package/dist/types/Utils/segmentation/tools/CrosshairTool.d.ts +30 -0
  98. package/dist/types/Utils/segmentation/tools/DragSliceTool.d.ts +44 -0
  99. package/dist/types/Utils/segmentation/tools/EraserTool.d.ts +16 -0
  100. package/dist/types/Utils/segmentation/tools/ImageStoreHelper.d.ts +65 -0
  101. package/dist/types/Utils/segmentation/tools/SphereTool.d.ts +38 -0
  102. package/dist/types/Utils/segmentation/tools/ZoomTool.d.ts +20 -0
  103. package/dist/types/Utils/segmentation/tools/index.d.ts +22 -0
  104. package/dist/types/index.d.ts +5 -3
  105. package/dist/types/types/types.d.ts +3 -3
  106. package/dist/types/types.d.ts +3 -3
  107. package/package.json +1 -1
  108. package/dist/Utils/segmentation/coreTools/archive.d.ts +0 -1
  109. package/dist/Utils/segmentation/coreTools/archive.js +0 -23
  110. package/dist/Utils/segmentation/coreTools/archive.js.map +0 -1
  111. package/dist/types/Utils/segmentation/coreTools/archive.d.ts +0 -1
@@ -0,0 +1,1238 @@
1
+ /**
2
+ * MaskVolume — True 3D Volumetric Mask Storage
3
+ *
4
+ * Stores annotation masks in a single contiguous Uint8Array with the memory
5
+ * layout [z][y][x][channel] (slice-major order).
6
+ *
7
+ * Design goals:
8
+ * - Contiguous memory for optimal cache locality and minimal GC pressure.
9
+ * - Multi-channel support (binary mask, confidence, labels, etc.).
10
+ * - O(1) voxel access with full bounds checking.
11
+ * - Slice extraction to ImageData for Canvas rendering (all 3 axes).
12
+ * - Multi-channel color mapping with 4 render modes.
13
+ *
14
+ * Memory comparison (512 × 512 × 100, 1 channel):
15
+ * Old ImageData approach ≈ 4.4 GB → MaskVolume ≈ 25 MB (~99 % reduction)
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * const vol = new MaskVolume(512, 512, 100); // 1 channel (default)
20
+ * vol.setVoxel(256, 256, 50, 255); // paint centre voxel
21
+ *
22
+ * // Grayscale slice (backward compatible)
23
+ * const slice = vol.getSliceImageData(50, 'z');
24
+ *
25
+ * // Colored slice
26
+ * const colored = vol.getSliceImageData(50, 'z', {
27
+ * mode: RenderMode.COLORED_SINGLE,
28
+ * channel: 0,
29
+ * opacity: 0.6,
30
+ * });
31
+ * ```
32
+ */
33
+ import { RenderMode, MASK_CHANNEL_COLORS } from './types';
34
+ export class MaskVolume {
35
+ // ── Constructor ────────────────────────────────────────────────────
36
+ /**
37
+ * Create a new MaskVolume.
38
+ *
39
+ * All voxels are initialised to **0**.
40
+ *
41
+ * @param width Number of voxels along the X axis (≥ 1).
42
+ * @param height Number of voxels along the Y axis (≥ 1).
43
+ * @param depth Number of voxels along the Z axis (≥ 1).
44
+ * @param channels Number of channels per voxel (default 1).
45
+ * @param customColorMap Optional color map to override defaults.
46
+ *
47
+ * @throws {RangeError} If any dimension or the channel count is < 1.
48
+ */
49
+ constructor(width, height, depth, channels = 1, customColorMap) {
50
+ if (width < 1 || height < 1 || depth < 1) {
51
+ throw new RangeError(`Dimensions must be ≥ 1, got (${width}, ${height}, ${depth})`);
52
+ }
53
+ if (channels < 1) {
54
+ throw new RangeError(`Channels must be ≥ 1, got ${channels}`);
55
+ }
56
+ this.dims = { width, height, depth };
57
+ this.numChannels = channels;
58
+ this.bytesPerSlice = width * height * channels;
59
+ const totalBytes = width * height * depth * channels;
60
+ this.data = new Uint8Array(totalBytes); // zero-initialised by spec
61
+ // Copy default colors, then overlay any custom overrides
62
+ this.colorMap = {};
63
+ for (const key of Object.keys(MASK_CHANNEL_COLORS)) {
64
+ const k = Number(key);
65
+ const c = MASK_CHANNEL_COLORS[k];
66
+ this.colorMap[k] = { r: c.r, g: c.g, b: c.b, a: c.a };
67
+ }
68
+ if (customColorMap) {
69
+ for (const key of Object.keys(customColorMap)) {
70
+ const k = Number(key);
71
+ const c = customColorMap[k];
72
+ this.colorMap[k] = { r: c.r, g: c.g, b: c.b, a: c.a };
73
+ }
74
+ }
75
+ }
76
+ // ── Index calculation ──────────────────────────────────────────────
77
+ /**
78
+ * Map 3D coordinates + channel to a flat 1D index.
79
+ *
80
+ * **Memory layout (slice-major):**
81
+ * ```
82
+ * index = (z × height × width × channels)
83
+ * + (y × width × channels)
84
+ * + (x × channels)
85
+ * + channel
86
+ * ```
87
+ *
88
+ * @param x Voxel column (0 … width − 1).
89
+ * @param y Voxel row (0 … height − 1).
90
+ * @param z Slice index (0 … depth − 1).
91
+ * @param channel Channel index (0 … channels − 1).
92
+ * @returns Flat 1D index into `this.data`.
93
+ *
94
+ * @throws {RangeError} If any coordinate or channel is out of bounds.
95
+ */
96
+ getIndex(x, y, z, channel = 0) {
97
+ if (x < 0 || x >= this.dims.width ||
98
+ y < 0 || y >= this.dims.height ||
99
+ z < 0 || z >= this.dims.depth ||
100
+ channel < 0 || channel >= this.numChannels) {
101
+ throw new RangeError(`Out of bounds: (x=${x}, y=${y}, z=${z}, ch=${channel}) ` +
102
+ `for volume (${this.dims.width}×${this.dims.height}×${this.dims.depth}×${this.numChannels})`);
103
+ }
104
+ return (z * this.bytesPerSlice +
105
+ y * this.dims.width * this.numChannels +
106
+ x * this.numChannels +
107
+ channel);
108
+ }
109
+ // ── Voxel access ───────────────────────────────────────────────────
110
+ /**
111
+ * Read a single voxel value.
112
+ *
113
+ * @param x Voxel column (0 … width − 1).
114
+ * @param y Voxel row (0 … height − 1).
115
+ * @param z Slice index (0 … depth − 1).
116
+ * @param channel Channel index (default 0).
117
+ * @returns The stored value (0 – 255).
118
+ *
119
+ * @throws {RangeError} If any coordinate is out of bounds.
120
+ */
121
+ getVoxel(x, y, z, channel = 0) {
122
+ return this.data[this.getIndex(x, y, z, channel)];
123
+ }
124
+ /**
125
+ * Write a single voxel value.
126
+ *
127
+ * Values are clamped to the Uint8 range [0, 255] by the typed array.
128
+ *
129
+ * @param x Voxel column (0 … width − 1).
130
+ * @param y Voxel row (0 … height − 1).
131
+ * @param z Slice index (0 … depth − 1).
132
+ * @param value New voxel value (0 – 255).
133
+ * @param channel Channel index (default 0).
134
+ *
135
+ * @throws {RangeError} If any coordinate is out of bounds.
136
+ */
137
+ setVoxel(x, y, z, value, channel = 0) {
138
+ this.data[this.getIndex(x, y, z, channel)] = value;
139
+ }
140
+ // ── Color map management ──────────────────────────────────────────
141
+ /**
142
+ * Update the color for a specific label/channel in the color map.
143
+ *
144
+ * For label-based volumes (1-channel), the channel parameter refers to
145
+ * the label value (0-8), not the storage channel index.
146
+ *
147
+ * @param channel Label/channel index to update (0-8).
148
+ * @param color New RGBA color.
149
+ *
150
+ * @throws {RangeError} If channel is outside valid label range [0, 8].
151
+ */
152
+ setChannelColor(channel, color) {
153
+ if (channel < 0 || channel > 8) {
154
+ throw new RangeError(`Invalid channel/label: ${channel} (valid range: 0-8)`);
155
+ }
156
+ this.colorMap[channel] = { r: color.r, g: color.g, b: color.b, a: color.a };
157
+ }
158
+ /**
159
+ * Get the current color for a channel.
160
+ *
161
+ * Falls back to the background color (transparent) if no color is defined.
162
+ *
163
+ * @param channel Channel index.
164
+ * @returns A copy of the RGBA color.
165
+ */
166
+ getChannelColor(channel) {
167
+ var _a;
168
+ const c = (_a = this.colorMap[channel]) !== null && _a !== void 0 ? _a : MASK_CHANNEL_COLORS[0];
169
+ return { r: c.r, g: c.g, b: c.b, a: c.a };
170
+ }
171
+ /**
172
+ * Reset the color map for one or all labels to defaults.
173
+ *
174
+ * @param channel Optional specific label to reset. If omitted, resets all.
175
+ */
176
+ resetChannelColors(channel) {
177
+ if (channel !== undefined) {
178
+ const c = MASK_CHANNEL_COLORS[channel];
179
+ if (c)
180
+ this.colorMap[channel] = { r: c.r, g: c.g, b: c.b, a: c.a };
181
+ }
182
+ else {
183
+ for (const key of Object.keys(MASK_CHANNEL_COLORS)) {
184
+ const k = Number(key);
185
+ const c = MASK_CHANNEL_COLORS[k];
186
+ this.colorMap[k] = { r: c.r, g: c.g, b: c.b, a: c.a };
187
+ }
188
+ }
189
+ }
190
+ /**
191
+ * Get a shallow copy of the entire color map.
192
+ *
193
+ * @returns A copy of the color map (safe to mutate).
194
+ */
195
+ getColorMap() {
196
+ const copy = {};
197
+ for (const key of Object.keys(this.colorMap)) {
198
+ const k = Number(key);
199
+ const c = this.colorMap[k];
200
+ copy[k] = { r: c.r, g: c.g, b: c.b, a: c.a };
201
+ }
202
+ return copy;
203
+ }
204
+ // ── Slice extraction ──────────────────────────────────────────────
205
+ /**
206
+ * Extract a 2D slice as an ImageData with color mapping.
207
+ *
208
+ * Slice dimensions depend on the axis:
209
+ *
210
+ * | Axis | Plane | Width | Height |
211
+ * |------|----------|--------|--------|
212
+ * | `z` | Axial | width | height |
213
+ * | `y` | Coronal | width | depth |
214
+ * | `x` | Sagittal | depth | height |
215
+ *
216
+ * @param sliceIndex Index along the specified axis.
217
+ * @param axis `'x'` (sagittal), `'y'` (coronal), or `'z'` (axial).
218
+ * @param options Rendering options (mode, channel, colors, etc.).
219
+ * @returns ImageData ready for `ctx.putImageData()`.
220
+ *
221
+ * @throws {RangeError} If sliceIndex is out of bounds for the given axis.
222
+ *
223
+ * @example
224
+ * ```ts
225
+ * // Grayscale (default, backward compatible)
226
+ * const gs = vol.getSliceImageData(50, 'z');
227
+ *
228
+ * // Colored single-channel
229
+ * const cs = vol.getSliceImageData(50, 'z', {
230
+ * mode: RenderMode.COLORED_SINGLE,
231
+ * channel: 0,
232
+ * });
233
+ *
234
+ * // Multi-channel priority
235
+ * const mc = vol.getSliceImageData(50, 'z', {
236
+ * mode: RenderMode.COLORED_MULTI,
237
+ * visibleChannels: [false, true, true, true],
238
+ * });
239
+ * ```
240
+ */
241
+ getSliceImageData(sliceIndex, axis = 'z', options = {}) {
242
+ // Validate sliceIndex against the correct axis dimension
243
+ this.validateSliceIndex(sliceIndex, axis);
244
+ const { mode = RenderMode.GRAYSCALE, channel = 0, colorMap = this.colorMap, visibleChannels, opacity = 1.0, } = options;
245
+ // Build default visibleChannels if not provided
246
+ const visCh = visibleChannels !== null && visibleChannels !== void 0 ? visibleChannels : new Array(this.numChannels).fill(true);
247
+ const [sliceWidth, sliceHeight] = this.getSliceDimensions(axis);
248
+ const imageData = new ImageData(sliceWidth, sliceHeight);
249
+ const pixels = imageData.data; // Uint8ClampedArray (RGBA)
250
+ switch (mode) {
251
+ case RenderMode.GRAYSCALE:
252
+ this.renderGrayscale(pixels, sliceWidth, sliceHeight, sliceIndex, axis, channel);
253
+ break;
254
+ case RenderMode.COLORED_SINGLE:
255
+ this.renderColoredSingle(pixels, sliceWidth, sliceHeight, sliceIndex, axis, channel, colorMap, opacity);
256
+ break;
257
+ case RenderMode.COLORED_MULTI:
258
+ this.renderColoredMulti(pixels, sliceWidth, sliceHeight, sliceIndex, axis, colorMap, visCh, opacity);
259
+ break;
260
+ case RenderMode.BLENDED:
261
+ this.renderBlended(pixels, sliceWidth, sliceHeight, sliceIndex, axis, colorMap, visCh, opacity);
262
+ break;
263
+ }
264
+ return imageData;
265
+ }
266
+ // ── Slice insertion ───────────────────────────────────────────────
267
+ /**
268
+ * Write a 2D ImageData back into the volume for the given axis/slice.
269
+ *
270
+ * When the volume has **4 channels**, all RGBA components are stored
271
+ * (ch0=R, ch1=G, ch2=B, ch3=A) for lossless round-trip.
272
+ * Otherwise, the **R channel** of each pixel is used as grayscale.
273
+ *
274
+ * @param sliceIndex Index along the specified axis.
275
+ * @param imageData Source ImageData (expected dimensions must match the axis).
276
+ * @param axis `'x'`, `'y'`, or `'z'` (default `'z'`).
277
+ * @param channel Target channel (default 0, ignored when numChannels >= 4).
278
+ *
279
+ * @throws {RangeError} If sliceIndex is out of bounds.
280
+ * @throws {Error} If imageData dimensions don't match.
281
+ *
282
+ * @example
283
+ * ```ts
284
+ * const imgData = ctx.getImageData(0, 0, 512, 512);
285
+ * vol.setSliceFromImageData(50, imgData, 'z');
286
+ * ```
287
+ */
288
+ setSliceFromImageData(sliceIndex, imageData, axis = 'z', channel = 0) {
289
+ this.validateSliceIndex(sliceIndex, axis);
290
+ const [expectedW, expectedH] = this.getSliceDimensions(axis);
291
+ if (imageData.width !== expectedW || imageData.height !== expectedH) {
292
+ throw new Error(`ImageData size mismatch: expected ${expectedW}×${expectedH}, ` +
293
+ `got ${imageData.width}×${imageData.height}`);
294
+ }
295
+ const pixels = imageData.data;
296
+ const { width, height } = this.dims;
297
+ const ch = this.numChannels;
298
+ const volData = this.data;
299
+ if (ch >= 4) {
300
+ // Store all 4 RGBA channels — direct memory access (no function calls)
301
+ if (axis === 'z') {
302
+ // Z-axis: slice data is contiguous — direct bulk copy
303
+ const offset = sliceIndex * this.bytesPerSlice;
304
+ volData.set(pixels.subarray(0, this.bytesPerSlice), offset);
305
+ }
306
+ else if (axis === 'y') {
307
+ // Y-axis: each row (fixed z, fixed y, varying x) is contiguous
308
+ const rowBytes = width * ch;
309
+ let px = 0;
310
+ for (let j = 0; j < expectedH; j++) {
311
+ const rowStart = j * height * width * ch + sliceIndex * width * ch;
312
+ volData.set(pixels.subarray(px, px + rowBytes), rowStart);
313
+ px += rowBytes;
314
+ }
315
+ }
316
+ else {
317
+ // X-axis: per-pixel inline math
318
+ // After dimension fix: expectedW = depth (Z), expectedH = height (Y)
319
+ // j → y (rows), i → z (columns)
320
+ let px = 0;
321
+ for (let j = 0; j < expectedH; j++) {
322
+ const yOffset = j * width * ch;
323
+ for (let i = 0; i < expectedW; i++) {
324
+ const baseIdx = i * height * width * ch + yOffset + sliceIndex * ch;
325
+ volData[baseIdx] = pixels[px];
326
+ volData[baseIdx + 1] = pixels[px + 1];
327
+ volData[baseIdx + 2] = pixels[px + 2];
328
+ volData[baseIdx + 3] = pixels[px + 3];
329
+ px += 4;
330
+ }
331
+ }
332
+ }
333
+ }
334
+ else {
335
+ // Legacy: single-channel grayscale (R channel only)
336
+ const rowStride = width * ch;
337
+ let baseIndex;
338
+ let jStride;
339
+ let iStride;
340
+ switch (axis) {
341
+ case 'z':
342
+ baseIndex = sliceIndex * this.bytesPerSlice + channel;
343
+ jStride = rowStride;
344
+ iStride = ch;
345
+ break;
346
+ case 'y':
347
+ baseIndex = sliceIndex * rowStride + channel;
348
+ jStride = this.bytesPerSlice;
349
+ iStride = ch;
350
+ break;
351
+ // Sagittal: i iterates over z (depth), j iterates over y (height)
352
+ case 'x':
353
+ baseIndex = sliceIndex * ch + channel;
354
+ jStride = rowStride; // j → y
355
+ iStride = this.bytesPerSlice; // i → z
356
+ break;
357
+ }
358
+ let px = 0;
359
+ for (let j = 0; j < expectedH; j++) {
360
+ let idx = baseIndex + j * jStride;
361
+ for (let i = 0; i < expectedW; i++) {
362
+ volData[idx] = pixels[px];
363
+ idx += iStride;
364
+ px += 4;
365
+ }
366
+ }
367
+ }
368
+ }
369
+ /**
370
+ * Extract a 2D slice as raw RGBA ImageData (lossless round-trip).
371
+ *
372
+ * Requires the volume to have **≥ 4 channels** (ch0=R, ch1=G, ch2=B, ch3=A).
373
+ * Returns the exact pixel data that was stored via `setSliceFromImageData`,
374
+ * bypassing all render modes.
375
+ *
376
+ * @param sliceIndex Index along the specified axis.
377
+ * @param axis `'x'`, `'y'`, or `'z'` (default `'z'`).
378
+ * @returns ImageData with the stored RGBA values.
379
+ *
380
+ * @throws {RangeError} If sliceIndex is out of bounds.
381
+ * @throws {Error} If the volume has fewer than 4 channels.
382
+ */
383
+ getSliceRawImageData(sliceIndex, axis = 'z') {
384
+ if (this.numChannels < 4) {
385
+ throw new Error(`getSliceRawImageData requires ≥ 4 channels, volume has ${this.numChannels}`);
386
+ }
387
+ this.validateSliceIndex(sliceIndex, axis);
388
+ const { width, height } = this.dims;
389
+ const ch = this.numChannels;
390
+ const [sliceWidth, sliceHeight] = this.getSliceDimensions(axis);
391
+ const imageData = new ImageData(sliceWidth, sliceHeight);
392
+ const pixels = imageData.data;
393
+ const volData = this.data;
394
+ if (axis === 'z') {
395
+ // Z-axis (axial): slice data is contiguous in memory — direct bulk copy
396
+ const offset = sliceIndex * this.bytesPerSlice;
397
+ pixels.set(volData.subarray(offset, offset + this.bytesPerSlice));
398
+ }
399
+ else if (axis === 'y') {
400
+ // Y-axis (coronal): each row (fixed z, fixed y, varying x) is contiguous
401
+ // mapping: (i → x, sliceIndex → y, j → z)
402
+ const rowBytes = width * ch;
403
+ let px = 0;
404
+ for (let j = 0; j < sliceHeight; j++) {
405
+ const rowStart = j * height * width * ch + sliceIndex * width * ch;
406
+ pixels.set(volData.subarray(rowStart, rowStart + rowBytes), px);
407
+ px += rowBytes;
408
+ }
409
+ }
410
+ else {
411
+ // X-axis (sagittal): pixels not contiguous, but avoid function call overhead
412
+ // After dimension fix: sliceWidth = depth (Z), sliceHeight = height (Y)
413
+ // j → y (rows), i → z (columns)
414
+ let px = 0;
415
+ for (let j = 0; j < sliceHeight; j++) {
416
+ const yOffset = j * width * ch;
417
+ for (let i = 0; i < sliceWidth; i++) {
418
+ const baseIdx = i * height * width * ch + yOffset + sliceIndex * ch;
419
+ pixels[px] = volData[baseIdx];
420
+ pixels[px + 1] = volData[baseIdx + 1];
421
+ pixels[px + 2] = volData[baseIdx + 2];
422
+ pixels[px + 3] = volData[baseIdx + 3];
423
+ px += 4;
424
+ }
425
+ }
426
+ }
427
+ return imageData;
428
+ }
429
+ /**
430
+ * Write slice data into an existing ImageData buffer (zero-allocation).
431
+ *
432
+ * Same semantics as {@link getSliceRawImageData} but avoids creating a new
433
+ * ImageData object on every call. The caller is responsible for providing
434
+ * a buffer whose dimensions match the expected slice size.
435
+ *
436
+ * All pixels are fully overwritten — no clearing is needed beforehand.
437
+ *
438
+ * @param sliceIndex Index along the specified axis.
439
+ * @param axis `'x'`, `'y'`, or `'z'` (default `'z'`).
440
+ * @param target Pre-allocated ImageData to write into.
441
+ *
442
+ * @throws {Error} If the volume has fewer than 4 channels.
443
+ * @throws {RangeError} If sliceIndex is out of bounds.
444
+ * @throws {Error} If target dimensions don't match the slice.
445
+ */
446
+ getSliceRawImageDataInto(sliceIndex, axis = 'z', target) {
447
+ if (this.numChannels < 4) {
448
+ throw new Error(`getSliceRawImageDataInto requires ≥ 4 channels, volume has ${this.numChannels}`);
449
+ }
450
+ this.validateSliceIndex(sliceIndex, axis);
451
+ const [sliceWidth, sliceHeight] = this.getSliceDimensions(axis);
452
+ if (target.width !== sliceWidth || target.height !== sliceHeight) {
453
+ throw new Error(`Buffer size mismatch: expected ${sliceWidth}×${sliceHeight}, ` +
454
+ `got ${target.width}×${target.height}`);
455
+ }
456
+ const { width, height } = this.dims;
457
+ const ch = this.numChannels;
458
+ const pixels = target.data;
459
+ const volData = this.data;
460
+ if (axis === 'z') {
461
+ const offset = sliceIndex * this.bytesPerSlice;
462
+ pixels.set(volData.subarray(offset, offset + this.bytesPerSlice));
463
+ }
464
+ else if (axis === 'y') {
465
+ const rowBytes = width * ch;
466
+ let px = 0;
467
+ for (let j = 0; j < sliceHeight; j++) {
468
+ const rowStart = j * height * width * ch + sliceIndex * width * ch;
469
+ pixels.set(volData.subarray(rowStart, rowStart + rowBytes), px);
470
+ px += rowBytes;
471
+ }
472
+ }
473
+ else {
474
+ // X-axis (sagittal): sliceWidth = depth (Z), sliceHeight = height (Y)
475
+ // j → y (rows), i → z (columns)
476
+ let px = 0;
477
+ for (let j = 0; j < sliceHeight; j++) {
478
+ const yOffset = j * width * ch;
479
+ for (let i = 0; i < sliceWidth; i++) {
480
+ const baseIdx = i * height * width * ch + yOffset + sliceIndex * ch;
481
+ pixels[px] = volData[baseIdx];
482
+ pixels[px + 1] = volData[baseIdx + 1];
483
+ pixels[px + 2] = volData[baseIdx + 2];
484
+ pixels[px + 3] = volData[baseIdx + 3];
485
+ px += 4;
486
+ }
487
+ }
488
+ }
489
+ }
490
+ // ── Label-based storage (1-channel volumes) ────────────────────────
491
+ /**
492
+ /**
493
+ * Store label data from canvas RGBA ImageData into a 1-channel volume.
494
+ *
495
+ * For each pixel: matches the RGB color against MASK_CHANNEL_COLORS to
496
+ * determine which channel label to store. If no match is found, falls
497
+ * back to `activeChannel`. Transparent pixels (alpha === 0) are stored as 0.
498
+ *
499
+ * This is the write counterpart to `renderLabelSliceInto`.
500
+ *
501
+ * @param sliceIndex Index along the specified axis.
502
+ * @param imageData Canvas ImageData (RGBA) to convert.
503
+ * @param axis `'x'`, `'y'`, or `'z'`.
504
+ * @param activeChannel Fallback label for pixels whose RGB doesn't match any channel (1-8).
505
+ * @param channelVisible Optional map of visible channels (true=visible, false=hidden).
506
+ * If provided, data for hidden channels will be preserved
507
+ * when the canvas pixel is transparent.
508
+ */
509
+ setSliceLabelsFromImageData(sliceIndex, imageData, axis = 'z', activeChannel = 1, channelVisible) {
510
+ this.validateSliceIndex(sliceIndex, axis);
511
+ const [expectedW, expectedH] = this.getSliceDimensions(axis);
512
+ if (imageData.width !== expectedW || imageData.height !== expectedH) {
513
+ throw new Error(`ImageData size mismatch: expected ${expectedW}×${expectedH}, ` +
514
+ `got ${imageData.width}×${imageData.height}`);
515
+ }
516
+ // Build RGB→channel lookup map for O(1) reverse lookup
517
+ const rgbToChannel = this.buildRgbToChannelMap();
518
+ const pixels = imageData.data;
519
+ // Alpha threshold to ignore anti-aliased edge fringe from canvas fill().
520
+ // Pixels with alpha < 128 are treated as transparent to prevent mask growth
521
+ // on each save→reload round-trip (semi-transparent edges would otherwise
522
+ // become fully opaque labels, expanding the mask by ~1px).
523
+ const ALPHA_THRESHOLD = 128;
524
+ const volData = this.data;
525
+ // Pre-compute strides to avoid per-pixel array allocation and bounds checks
526
+ const nch = this.numChannels;
527
+ const rowStride = this.dims.width * nch;
528
+ let baseIndex;
529
+ let jStride;
530
+ let iStride;
531
+ switch (axis) {
532
+ case 'z':
533
+ baseIndex = sliceIndex * this.bytesPerSlice;
534
+ jStride = rowStride;
535
+ iStride = nch;
536
+ break;
537
+ case 'y':
538
+ baseIndex = sliceIndex * rowStride;
539
+ jStride = this.bytesPerSlice;
540
+ iStride = nch;
541
+ break;
542
+ // Sagittal: i iterates over z (depth), j iterates over y (height)
543
+ case 'x':
544
+ baseIndex = sliceIndex * nch;
545
+ jStride = rowStride; // j → y
546
+ iStride = this.bytesPerSlice; // i → z
547
+ break;
548
+ }
549
+ let px = 0;
550
+ for (let j = 0; j < expectedH; j++) {
551
+ let idx = baseIndex + j * jStride;
552
+ for (let i = 0; i < expectedW; i++) {
553
+ const alpha = pixels[px + 3];
554
+ if (alpha < ALPHA_THRESHOLD) {
555
+ // Transparent or semi-transparent fringe
556
+ if (channelVisible) {
557
+ const existingLabel = volData[idx];
558
+ if (existingLabel !== 0 && channelVisible[existingLabel] === false) {
559
+ idx += iStride;
560
+ px += 4;
561
+ continue;
562
+ }
563
+ }
564
+ volData[idx] = 0;
565
+ }
566
+ else {
567
+ // Match RGB against known channel colors
568
+ const r = pixels[px];
569
+ const g = pixels[px + 1];
570
+ const b = pixels[px + 2];
571
+ const key = (r << 16) | (g << 8) | b;
572
+ const matchedChannel = rgbToChannel.get(key);
573
+ volData[idx] = matchedChannel !== undefined ? matchedChannel : activeChannel;
574
+ }
575
+ idx += iStride;
576
+ px += 4;
577
+ }
578
+ }
579
+ }
580
+ /**
581
+ * Build a Map from RGB packed integer to channel label for reverse lookup.
582
+ * Uses this instance's colorMap (channels 1-8, skips 0 = transparent).
583
+ *
584
+ * Instance method ensures custom per-layer colors are correctly reverse-mapped.
585
+ */
586
+ buildRgbToChannelMap() {
587
+ var _a;
588
+ const map = new Map();
589
+ for (let ch = 1; ch <= 8; ch++) {
590
+ const color = (_a = this.colorMap[ch]) !== null && _a !== void 0 ? _a : MASK_CHANNEL_COLORS[ch];
591
+ if (color) {
592
+ const key = (color.r << 16) | (color.g << 8) | color.b;
593
+ map.set(key, ch);
594
+ }
595
+ }
596
+ return map;
597
+ }
598
+ // ── Label-based rendering (1-channel volumes) ─────────────────────
599
+ /**
600
+ * Render a 1-channel label slice into a pre-allocated RGBA ImageData buffer.
601
+ *
602
+ * Each voxel stores a label value (0-8). Label 0 = transparent.
603
+ * Labels 1-8 are mapped to colors via MASK_CHANNEL_COLORS, filtered
604
+ * by the channelVisible array.
605
+ *
606
+ * @param sliceIndex Index along the specified axis.
607
+ * @param axis `'x'`, `'y'`, or `'z'`.
608
+ * @param target Pre-allocated ImageData buffer (must match slice dimensions).
609
+ * @param channelVisible Array where index N indicates if channel N is visible.
610
+ * If undefined, all channels are visible.
611
+ * @param opacity Opacity multiplier 0.0–1.0 (default 1.0).
612
+ */
613
+ renderLabelSliceInto(sliceIndex, axis = 'z', target, channelVisible, opacity = 1.0) {
614
+ var _a, _b;
615
+ this.validateSliceIndex(sliceIndex, axis);
616
+ const [sliceWidth, sliceHeight] = this.getSliceDimensions(axis);
617
+ if (target.width !== sliceWidth || target.height !== sliceHeight) {
618
+ throw new Error(`Buffer size mismatch: expected ${sliceWidth}×${sliceHeight}, ` +
619
+ `got ${target.width}×${target.height}`);
620
+ }
621
+ const pixels = target.data;
622
+ const data = this.data;
623
+ const colorMap = this.colorMap;
624
+ // Pre-compute strides to avoid per-pixel mapSliceToVolume allocation
625
+ // and per-pixel getIndex bounds checking.
626
+ const ch = this.numChannels;
627
+ const rowStride = this.dims.width * ch;
628
+ let baseIndex;
629
+ let jStride;
630
+ let iStride;
631
+ switch (axis) {
632
+ case 'z':
633
+ baseIndex = sliceIndex * this.bytesPerSlice;
634
+ jStride = rowStride;
635
+ iStride = ch;
636
+ break;
637
+ case 'y':
638
+ baseIndex = sliceIndex * rowStride;
639
+ jStride = this.bytesPerSlice;
640
+ iStride = ch;
641
+ break;
642
+ // Sagittal: i iterates over z (depth), j iterates over y (height)
643
+ case 'x':
644
+ baseIndex = sliceIndex * ch;
645
+ jStride = rowStride; // j → y
646
+ iStride = this.bytesPerSlice; // i → z
647
+ break;
648
+ }
649
+ let px = 0;
650
+ for (let j = 0; j < sliceHeight; j++) {
651
+ let idx = baseIndex + j * jStride;
652
+ for (let i = 0; i < sliceWidth; i++) {
653
+ const label = data[idx];
654
+ if (label === 0) {
655
+ pixels[px] = 0;
656
+ pixels[px + 1] = 0;
657
+ pixels[px + 2] = 0;
658
+ pixels[px + 3] = 0;
659
+ }
660
+ else if (channelVisible && !channelVisible[label]) {
661
+ pixels[px] = 0;
662
+ pixels[px + 1] = 0;
663
+ pixels[px + 2] = 0;
664
+ pixels[px + 3] = 0;
665
+ }
666
+ else {
667
+ const color = (_b = (_a = colorMap[label]) !== null && _a !== void 0 ? _a : MASK_CHANNEL_COLORS[label]) !== null && _b !== void 0 ? _b : MASK_CHANNEL_COLORS[1];
668
+ pixels[px] = color.r;
669
+ pixels[px + 1] = color.g;
670
+ pixels[px + 2] = color.b;
671
+ pixels[px + 3] = Math.round(color.a * opacity);
672
+ }
673
+ idx += iStride;
674
+ px += 4;
675
+ }
676
+ }
677
+ }
678
+ // ── Accessors ──────────────────────────────────────────────────────
679
+ /**
680
+ * Return a copy of the volume dimensions.
681
+ *
682
+ * @returns `{ width, height, depth }` — a fresh object (safe to mutate).
683
+ */
684
+ getDimensions() {
685
+ return Object.assign({}, this.dims);
686
+ }
687
+ /**
688
+ * Return the number of channels per voxel.
689
+ *
690
+ * @returns Channel count (≥ 1).
691
+ */
692
+ getChannels() {
693
+ return this.numChannels;
694
+ }
695
+ /**
696
+ * Return total memory used by the backing buffer, in bytes.
697
+ *
698
+ * @returns `width × height × depth × channels` bytes.
699
+ *
700
+ * @example
701
+ * ```ts
702
+ * const vol = new MaskVolume(512, 512, 100);
703
+ * vol.getMemoryUsage(); // 26_214_400 (~25 MB)
704
+ * ```
705
+ */
706
+ getMemoryUsage() {
707
+ return this.data.byteLength;
708
+ }
709
+ // ── Utility methods ─────────────────────────────────────────────────
710
+ /**
711
+ * Return a **reference** to the raw backing buffer.
712
+ *
713
+ * Use this for direct read access (e.g. serialisation, GPU upload).
714
+ * Mutating the returned array mutates the volume.
715
+ *
716
+ * @returns The underlying Uint8Array.
717
+ *
718
+ * @example
719
+ * ```ts
720
+ * // Serialise for network transfer
721
+ * const bytes = vol.getRawData();
722
+ * socket.send(bytes.buffer);
723
+ * ```
724
+ */
725
+ getRawData() {
726
+ return this.data;
727
+ }
728
+ /**
729
+ * Replace the entire backing buffer.
730
+ *
731
+ * The provided array **must** have exactly the same length as the
732
+ * current buffer (`width × height × depth × channels`).
733
+ *
734
+ * @param newData Replacement data.
735
+ * @throws {Error} If the length does not match.
736
+ *
737
+ * @example
738
+ * ```ts
739
+ * // Restore from a saved snapshot
740
+ * const saved = new Uint8Array(savedBuffer);
741
+ * vol.setRawData(saved);
742
+ * ```
743
+ */
744
+ setRawData(newData) {
745
+ if (newData.length !== this.data.length) {
746
+ throw new Error(`Data length mismatch: expected ${this.data.length}, got ${newData.length}`);
747
+ }
748
+ this.data = newData;
749
+ }
750
+ /**
751
+ * Create an independent deep copy of this volume.
752
+ *
753
+ * The clone has the same dimensions, channels, data, and color map
754
+ * but shares no references with the original.
755
+ *
756
+ * @returns A new MaskVolume with identical content.
757
+ *
758
+ * @example
759
+ * ```ts
760
+ * // Undo support: snapshot before an operation
761
+ * const snapshot = vol.clone();
762
+ * performEdit(vol);
763
+ * // Rollback: vol.setRawData(snapshot.getRawData());
764
+ * ```
765
+ */
766
+ clone() {
767
+ const copy = new MaskVolume(this.dims.width, this.dims.height, this.dims.depth, this.numChannels);
768
+ copy.data.set(this.data);
769
+ // Deep-copy the color map
770
+ for (const key of Object.keys(this.colorMap)) {
771
+ const k = Number(key);
772
+ const c = this.colorMap[k];
773
+ copy.colorMap[k] = { r: c.r, g: c.g, b: c.b, a: c.a };
774
+ }
775
+ return copy;
776
+ }
777
+ /**
778
+ * Zero every voxel in the entire volume (all channels).
779
+ *
780
+ * @example
781
+ * ```ts
782
+ * vol.clear();
783
+ * vol.getVoxel(256, 256, 50); // 0
784
+ * ```
785
+ */
786
+ clear() {
787
+ this.data.fill(0);
788
+ }
789
+ /**
790
+ * Check if the volume contains any non-zero voxel data.
791
+ *
792
+ * Returns true if at least one voxel has a non-zero value,
793
+ * false if all voxels are zero (empty mask).
794
+ *
795
+ * This is useful for determining if a layer has actual mask
796
+ * annotations before saving or exporting.
797
+ *
798
+ * @returns True if any voxel is non-zero, false if all zeros.
799
+ *
800
+ * @example
801
+ * ```ts
802
+ * const vol = new MaskVolume(512, 512, 100);
803
+ * vol.hasData(); // false (all zeros)
804
+ *
805
+ * vol.setVoxel(256, 256, 50, 255);
806
+ * vol.hasData(); // true
807
+ * ```
808
+ */
809
+ hasData() {
810
+ for (let i = 0; i < this.data.length; i++) {
811
+ if (this.data[i] !== 0) {
812
+ return true;
813
+ }
814
+ }
815
+ return false;
816
+ }
817
+ /**
818
+ * Zero every voxel in a single slice along the given axis.
819
+ *
820
+ * If `channel` is provided, only that channel is cleared;
821
+ * otherwise **all** channels in the slice are cleared.
822
+ *
823
+ * @param sliceIndex Index along the specified axis.
824
+ * @param axis `'x'`, `'y'`, or `'z'` (default `'z'`).
825
+ * @param channel Optional channel to clear (default: all).
826
+ *
827
+ * @throws {RangeError} If sliceIndex is out of bounds.
828
+ *
829
+ * @example
830
+ * ```ts
831
+ * // Erase a single axial slice
832
+ * vol.clearSlice(50, 'z');
833
+ *
834
+ * // Erase only channel 1 of slice 50
835
+ * vol.clearSlice(50, 'z', 1);
836
+ * ```
837
+ */
838
+ clearSlice(sliceIndex, axis = 'z', channel) {
839
+ this.validateSliceIndex(sliceIndex, axis);
840
+ const [sliceW, sliceH] = this.getSliceDimensions(axis);
841
+ const volData = this.data;
842
+ const nch = this.numChannels;
843
+ const rowStride = this.dims.width * nch;
844
+ let baseIndex;
845
+ let jStride;
846
+ let iStride;
847
+ switch (axis) {
848
+ case 'z':
849
+ baseIndex = sliceIndex * this.bytesPerSlice;
850
+ jStride = rowStride;
851
+ iStride = nch;
852
+ break;
853
+ case 'y':
854
+ baseIndex = sliceIndex * rowStride;
855
+ jStride = this.bytesPerSlice;
856
+ iStride = nch;
857
+ break;
858
+ // Sagittal: i iterates over z (depth), j iterates over y (height)
859
+ case 'x':
860
+ baseIndex = sliceIndex * nch;
861
+ jStride = rowStride; // j → y
862
+ iStride = this.bytesPerSlice; // i → z
863
+ break;
864
+ }
865
+ for (let j = 0; j < sliceH; j++) {
866
+ let idx = baseIndex + j * jStride;
867
+ for (let i = 0; i < sliceW; i++) {
868
+ if (channel !== undefined) {
869
+ volData[idx + channel] = 0;
870
+ }
871
+ else {
872
+ for (let ch = 0; ch < nch; ch++) {
873
+ volData[idx + ch] = 0;
874
+ }
875
+ }
876
+ idx += iStride;
877
+ }
878
+ }
879
+ }
880
+ // ── Slice extraction (raw Uint8Array) ────────────────────────────
881
+ /**
882
+ * Extract a 2D slice as a flat Uint8Array (one byte per voxel per channel).
883
+ *
884
+ * For a 1-channel volume the result is `sliceWidth × sliceHeight` bytes,
885
+ * each byte being the label value at that pixel. This is suitable for
886
+ * sending to a backend that expects raw label data (e.g. NIfTI slice
887
+ * replacement).
888
+ *
889
+ * @param sliceIndex Index along the specified axis.
890
+ * @param axis `'x'`, `'y'`, or `'z'` (default `'z'`).
891
+ * @returns A **copy** of the slice data plus its dimensions.
892
+ *
893
+ * @throws {RangeError} If sliceIndex is out of bounds.
894
+ *
895
+ * @example
896
+ * ```ts
897
+ * const { data, width, height } = vol.getSliceUint8(50, 'z');
898
+ * // data.length === width * height * channels
899
+ * ```
900
+ */
901
+ getSliceUint8(sliceIndex, axis = 'z') {
902
+ this.validateSliceIndex(sliceIndex, axis);
903
+ const [sliceW, sliceH] = this.getSliceDimensions(axis);
904
+ const nch = this.numChannels;
905
+ const sliceSize = sliceW * sliceH * nch;
906
+ const result = new Uint8Array(sliceSize);
907
+ const volData = this.data;
908
+ const rowStride = this.dims.width * nch;
909
+ if (axis === 'z') {
910
+ // Contiguous — bulk copy
911
+ const offset = sliceIndex * this.bytesPerSlice;
912
+ result.set(volData.subarray(offset, offset + sliceSize));
913
+ }
914
+ else if (axis === 'y') {
915
+ let dst = 0;
916
+ for (let j = 0; j < sliceH; j++) {
917
+ const rowStart = j * this.dims.height * this.dims.width * nch + sliceIndex * rowStride;
918
+ result.set(volData.subarray(rowStart, rowStart + this.dims.width * nch), dst);
919
+ dst += this.dims.width * nch;
920
+ }
921
+ }
922
+ else {
923
+ // X-axis (sagittal): sliceW = depth, sliceH = height
924
+ let dst = 0;
925
+ for (let j = 0; j < sliceH; j++) {
926
+ const yOffset = j * this.dims.width * nch;
927
+ for (let i = 0; i < sliceW; i++) {
928
+ const baseIdx = i * this.dims.height * this.dims.width * nch + yOffset + sliceIndex * nch;
929
+ for (let ch = 0; ch < nch; ch++) {
930
+ result[dst++] = volData[baseIdx + ch];
931
+ }
932
+ }
933
+ }
934
+ }
935
+ return { data: result, width: sliceW, height: sliceH };
936
+ }
937
+ /**
938
+ * Write a 2D slice from a flat Uint8Array back into the volume.
939
+ *
940
+ * This is the inverse of {@link getSliceUint8}. The `data` array must
941
+ * have exactly `sliceWidth × sliceHeight × channels` bytes.
942
+ *
943
+ * @param sliceIndex Index along the specified axis.
944
+ * @param data Flat source array (same layout as returned by getSliceUint8).
945
+ * @param axis `'x'`, `'y'`, or `'z'` (default `'z'`).
946
+ *
947
+ * @throws {RangeError} If sliceIndex is out of bounds.
948
+ */
949
+ setSliceUint8(sliceIndex, data, axis = 'z') {
950
+ this.validateSliceIndex(sliceIndex, axis);
951
+ const [sliceW, sliceH] = this.getSliceDimensions(axis);
952
+ const nch = this.numChannels;
953
+ const rowStride = this.dims.width * nch;
954
+ const volData = this.data;
955
+ if (axis === 'z') {
956
+ // Contiguous — bulk copy
957
+ const offset = sliceIndex * this.bytesPerSlice;
958
+ volData.set(data.subarray(0, sliceW * sliceH * nch), offset);
959
+ }
960
+ else if (axis === 'y') {
961
+ let src = 0;
962
+ for (let j = 0; j < sliceH; j++) {
963
+ const rowStart = j * this.dims.height * this.dims.width * nch + sliceIndex * rowStride;
964
+ volData.set(data.subarray(src, src + this.dims.width * nch), rowStart);
965
+ src += this.dims.width * nch;
966
+ }
967
+ }
968
+ else {
969
+ // X-axis (sagittal): sliceW = depth, sliceH = height
970
+ let src = 0;
971
+ for (let j = 0; j < sliceH; j++) {
972
+ const yOffset = j * this.dims.width * nch;
973
+ for (let i = 0; i < sliceW; i++) {
974
+ const baseIdx = i * this.dims.height * this.dims.width * nch + yOffset + sliceIndex * nch;
975
+ for (let ch = 0; ch < nch; ch++) {
976
+ volData[baseIdx + ch] = data[src++];
977
+ }
978
+ }
979
+ }
980
+ }
981
+ }
982
+ // ── Private helpers ───────────────────────────────────────────────
983
+ /**
984
+ * Get the 2D slice dimensions for a given axis.
985
+ *
986
+ * @returns `[width, height]` of the slice.
987
+ */
988
+ getSliceDimensions(axis) {
989
+ const { width, height, depth } = this.dims;
990
+ switch (axis) {
991
+ case 'z': return [width, height];
992
+ case 'y': return [width, depth];
993
+ // Sagittal: canvas width = Z (depth), canvas height = Y (height)
994
+ // Matches Three.js originCanvas layout and setEmptyCanvasSize('x')
995
+ case 'x': return [depth, height];
996
+ }
997
+ }
998
+ /**
999
+ * Validate that a slice index is within bounds for the given axis.
1000
+ *
1001
+ * @throws {RangeError} If out of bounds.
1002
+ */
1003
+ validateSliceIndex(sliceIndex, axis) {
1004
+ const max = axis === 'x' ? this.dims.width :
1005
+ axis === 'y' ? this.dims.height :
1006
+ this.dims.depth;
1007
+ if (sliceIndex < 0 || sliceIndex >= max) {
1008
+ throw new RangeError(`Slice index ${sliceIndex} out of bounds for axis '${axis}' (max ${max - 1})`);
1009
+ }
1010
+ }
1011
+ // ── Render modes ──────────────────────────────────────────────────
1012
+ /**
1013
+ * Render a single channel as grayscale (original behaviour).
1014
+ *
1015
+ * Non-zero voxels are fully opaque (A = 255), zero voxels are
1016
+ * transparent (A = 0).
1017
+ */
1018
+ renderGrayscale(pixels, width, height, sliceIndex, axis, channel) {
1019
+ const data = this.data;
1020
+ const nch = this.numChannels;
1021
+ const rowStride = this.dims.width * nch;
1022
+ let baseIndex;
1023
+ let jStride;
1024
+ let iStride;
1025
+ switch (axis) {
1026
+ case 'z':
1027
+ baseIndex = sliceIndex * this.bytesPerSlice + channel;
1028
+ jStride = rowStride;
1029
+ iStride = nch;
1030
+ break;
1031
+ case 'y':
1032
+ baseIndex = sliceIndex * rowStride + channel;
1033
+ jStride = this.bytesPerSlice;
1034
+ iStride = nch;
1035
+ break;
1036
+ // Sagittal: i iterates over z (depth), j iterates over y (height)
1037
+ case 'x':
1038
+ baseIndex = sliceIndex * nch + channel;
1039
+ jStride = rowStride; // j → y
1040
+ iStride = this.bytesPerSlice; // i → z
1041
+ break;
1042
+ }
1043
+ let px = 0;
1044
+ for (let j = 0; j < height; j++) {
1045
+ let idx = baseIndex + j * jStride;
1046
+ for (let i = 0; i < width; i++) {
1047
+ const value = data[idx];
1048
+ pixels[px] = value;
1049
+ pixels[px + 1] = value;
1050
+ pixels[px + 2] = value;
1051
+ pixels[px + 3] = value > 0 ? 255 : 0;
1052
+ idx += iStride;
1053
+ px += 4;
1054
+ }
1055
+ }
1056
+ }
1057
+ /**
1058
+ * Render a single channel with its predefined color.
1059
+ *
1060
+ * Alpha is modulated by the voxel intensity and the opacity multiplier.
1061
+ */
1062
+ renderColoredSingle(pixels, width, height, sliceIndex, axis, channel, colorMap, opacity) {
1063
+ var _a;
1064
+ const color = (_a = colorMap[channel]) !== null && _a !== void 0 ? _a : MASK_CHANNEL_COLORS[1];
1065
+ const data = this.data;
1066
+ const nch = this.numChannels;
1067
+ const rowStride = this.dims.width * nch;
1068
+ let baseIndex;
1069
+ let jStride;
1070
+ let iStride;
1071
+ switch (axis) {
1072
+ case 'z':
1073
+ baseIndex = sliceIndex * this.bytesPerSlice + channel;
1074
+ jStride = rowStride;
1075
+ iStride = nch;
1076
+ break;
1077
+ case 'y':
1078
+ baseIndex = sliceIndex * rowStride + channel;
1079
+ jStride = this.bytesPerSlice;
1080
+ iStride = nch;
1081
+ break;
1082
+ // Sagittal: i iterates over z (depth), j iterates over y (height)
1083
+ case 'x':
1084
+ baseIndex = sliceIndex * nch + channel;
1085
+ jStride = rowStride; // j → y
1086
+ iStride = this.bytesPerSlice; // i → z
1087
+ break;
1088
+ }
1089
+ let px = 0;
1090
+ for (let j = 0; j < height; j++) {
1091
+ let idx = baseIndex + j * jStride;
1092
+ for (let i = 0; i < width; i++) {
1093
+ const value = data[idx];
1094
+ if (value > 0) {
1095
+ const intensity = value / 255;
1096
+ pixels[px] = color.r;
1097
+ pixels[px + 1] = color.g;
1098
+ pixels[px + 2] = color.b;
1099
+ pixels[px + 3] = Math.round(color.a * intensity * opacity);
1100
+ }
1101
+ else {
1102
+ pixels[px + 3] = 0;
1103
+ }
1104
+ idx += iStride;
1105
+ px += 4;
1106
+ }
1107
+ }
1108
+ }
1109
+ /**
1110
+ * Render all visible channels with distinct colors.
1111
+ *
1112
+ * The **highest-index** non-zero visible channel wins (priority-based).
1113
+ */
1114
+ renderColoredMulti(pixels, width, height, sliceIndex, axis, colorMap, visibleChannels, opacity) {
1115
+ var _a, _b;
1116
+ const data = this.data;
1117
+ const nch = this.numChannels;
1118
+ const rowStride = this.dims.width * nch;
1119
+ let baseIndex;
1120
+ let jStride;
1121
+ let iStride;
1122
+ switch (axis) {
1123
+ case 'z':
1124
+ baseIndex = sliceIndex * this.bytesPerSlice;
1125
+ jStride = rowStride;
1126
+ iStride = nch;
1127
+ break;
1128
+ case 'y':
1129
+ baseIndex = sliceIndex * rowStride;
1130
+ jStride = this.bytesPerSlice;
1131
+ iStride = nch;
1132
+ break;
1133
+ // Sagittal: i iterates over z (depth), j iterates over y (height)
1134
+ case 'x':
1135
+ baseIndex = sliceIndex * nch;
1136
+ jStride = rowStride; // j → y
1137
+ iStride = this.bytesPerSlice; // i → z
1138
+ break;
1139
+ }
1140
+ let px = 0;
1141
+ for (let j = 0; j < height; j++) {
1142
+ let idx = baseIndex + j * jStride;
1143
+ for (let i = 0; i < width; i++) {
1144
+ let found = false;
1145
+ // Iterate highest-to-lowest channel; first non-zero wins
1146
+ for (let ch = nch - 1; ch >= 0; ch--) {
1147
+ if (!visibleChannels[ch])
1148
+ continue;
1149
+ const value = data[idx + ch];
1150
+ if (value > 0) {
1151
+ const color = (_b = (_a = colorMap[ch]) !== null && _a !== void 0 ? _a : MASK_CHANNEL_COLORS[ch]) !== null && _b !== void 0 ? _b : MASK_CHANNEL_COLORS[1];
1152
+ const intensity = value / 255;
1153
+ pixels[px] = color.r;
1154
+ pixels[px + 1] = color.g;
1155
+ pixels[px + 2] = color.b;
1156
+ pixels[px + 3] = Math.round(color.a * intensity * opacity);
1157
+ found = true;
1158
+ break;
1159
+ }
1160
+ }
1161
+ if (!found) {
1162
+ pixels[px + 3] = 0;
1163
+ }
1164
+ idx += iStride;
1165
+ px += 4;
1166
+ }
1167
+ }
1168
+ }
1169
+ /**
1170
+ * Render all visible channels with additive blending.
1171
+ *
1172
+ * Each channel contributes its color weighted by intensity × opacity.
1173
+ * RGB values are clamped to 255.
1174
+ */
1175
+ renderBlended(pixels, width, height, sliceIndex, axis, colorMap, visibleChannels, opacity) {
1176
+ var _a, _b;
1177
+ const data = this.data;
1178
+ const nch = this.numChannels;
1179
+ const rowStride = this.dims.width * nch;
1180
+ let baseIndex;
1181
+ let jStride;
1182
+ let iStride;
1183
+ switch (axis) {
1184
+ case 'z':
1185
+ baseIndex = sliceIndex * this.bytesPerSlice;
1186
+ jStride = rowStride;
1187
+ iStride = nch;
1188
+ break;
1189
+ case 'y':
1190
+ baseIndex = sliceIndex * rowStride;
1191
+ jStride = this.bytesPerSlice;
1192
+ iStride = nch;
1193
+ break;
1194
+ // Sagittal: i iterates over z (depth), j iterates over y (height)
1195
+ case 'x':
1196
+ baseIndex = sliceIndex * nch;
1197
+ jStride = rowStride; // j → y
1198
+ iStride = this.bytesPerSlice; // i → z
1199
+ break;
1200
+ }
1201
+ let px = 0;
1202
+ for (let j = 0; j < height; j++) {
1203
+ let idx = baseIndex + j * jStride;
1204
+ for (let i = 0; i < width; i++) {
1205
+ let totalR = 0;
1206
+ let totalG = 0;
1207
+ let totalB = 0;
1208
+ let totalA = 0;
1209
+ for (let ch = 0; ch < nch; ch++) {
1210
+ if (!visibleChannels[ch])
1211
+ continue;
1212
+ const value = data[idx + ch];
1213
+ if (value > 0) {
1214
+ const color = (_b = (_a = colorMap[ch]) !== null && _a !== void 0 ? _a : MASK_CHANNEL_COLORS[ch]) !== null && _b !== void 0 ? _b : MASK_CHANNEL_COLORS[1];
1215
+ const intensity = value / 255;
1216
+ const alpha = (color.a / 255) * intensity * opacity;
1217
+ totalR += color.r * alpha;
1218
+ totalG += color.g * alpha;
1219
+ totalB += color.b * alpha;
1220
+ totalA += alpha;
1221
+ }
1222
+ }
1223
+ if (totalA > 0) {
1224
+ pixels[px] = Math.min(255, Math.round(totalR));
1225
+ pixels[px + 1] = Math.min(255, Math.round(totalG));
1226
+ pixels[px + 2] = Math.min(255, Math.round(totalB));
1227
+ pixels[px + 3] = Math.min(255, Math.round(totalA * 255));
1228
+ }
1229
+ else {
1230
+ pixels[px + 3] = 0;
1231
+ }
1232
+ idx += iStride;
1233
+ px += 4;
1234
+ }
1235
+ }
1236
+ }
1237
+ }
1238
+ //# sourceMappingURL=MaskVolume.js.map