@fideus-labs/fidnii 0.1.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 (58) hide show
  1. package/LICENSE.txt +9 -0
  2. package/README.md +180 -0
  3. package/dist/BufferManager.d.ts +86 -0
  4. package/dist/BufferManager.d.ts.map +1 -0
  5. package/dist/BufferManager.js +146 -0
  6. package/dist/BufferManager.js.map +1 -0
  7. package/dist/ClipPlanes.d.ts +180 -0
  8. package/dist/ClipPlanes.d.ts.map +1 -0
  9. package/dist/ClipPlanes.js +513 -0
  10. package/dist/ClipPlanes.js.map +1 -0
  11. package/dist/OMEZarrNVImage.d.ts +545 -0
  12. package/dist/OMEZarrNVImage.d.ts.map +1 -0
  13. package/dist/OMEZarrNVImage.js +1799 -0
  14. package/dist/OMEZarrNVImage.js.map +1 -0
  15. package/dist/RegionCoalescer.d.ts +75 -0
  16. package/dist/RegionCoalescer.d.ts.map +1 -0
  17. package/dist/RegionCoalescer.js +151 -0
  18. package/dist/RegionCoalescer.js.map +1 -0
  19. package/dist/ResolutionSelector.d.ts +88 -0
  20. package/dist/ResolutionSelector.d.ts.map +1 -0
  21. package/dist/ResolutionSelector.js +224 -0
  22. package/dist/ResolutionSelector.js.map +1 -0
  23. package/dist/ViewportBounds.d.ts +50 -0
  24. package/dist/ViewportBounds.d.ts.map +1 -0
  25. package/dist/ViewportBounds.js +325 -0
  26. package/dist/ViewportBounds.js.map +1 -0
  27. package/dist/events.d.ts +122 -0
  28. package/dist/events.d.ts.map +1 -0
  29. package/dist/events.js +12 -0
  30. package/dist/events.js.map +1 -0
  31. package/dist/index.d.ts +48 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +59 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/types.d.ts +273 -0
  36. package/dist/types.d.ts.map +1 -0
  37. package/dist/types.js +126 -0
  38. package/dist/types.js.map +1 -0
  39. package/dist/utils/affine.d.ts +72 -0
  40. package/dist/utils/affine.d.ts.map +1 -0
  41. package/dist/utils/affine.js +173 -0
  42. package/dist/utils/affine.js.map +1 -0
  43. package/dist/utils/coordinates.d.ts +80 -0
  44. package/dist/utils/coordinates.d.ts.map +1 -0
  45. package/dist/utils/coordinates.js +207 -0
  46. package/dist/utils/coordinates.js.map +1 -0
  47. package/package.json +61 -0
  48. package/src/BufferManager.ts +176 -0
  49. package/src/ClipPlanes.ts +640 -0
  50. package/src/OMEZarrNVImage.ts +2286 -0
  51. package/src/RegionCoalescer.ts +217 -0
  52. package/src/ResolutionSelector.ts +325 -0
  53. package/src/ViewportBounds.ts +369 -0
  54. package/src/events.ts +146 -0
  55. package/src/index.ts +153 -0
  56. package/src/types.ts +429 -0
  57. package/src/utils/affine.ts +218 -0
  58. package/src/utils/coordinates.ts +271 -0
@@ -0,0 +1,369 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Niivue } from "@niivue/niivue";
5
+ import { SLICE_TYPE } from "@niivue/niivue";
6
+ import type { VolumeBounds } from "./types.js";
7
+
8
+ /**
9
+ * Intersect two axis-aligned bounding boxes.
10
+ * Returns the overlapping region, clamped so max >= min.
11
+ */
12
+ export function intersectBounds(
13
+ a: VolumeBounds,
14
+ b: VolumeBounds,
15
+ ): VolumeBounds {
16
+ const min: [number, number, number] = [
17
+ Math.max(a.min[0], b.min[0]),
18
+ Math.max(a.min[1], b.min[1]),
19
+ Math.max(a.min[2], b.min[2]),
20
+ ];
21
+ const max: [number, number, number] = [
22
+ Math.min(a.max[0], b.max[0]),
23
+ Math.min(a.max[1], b.max[1]),
24
+ Math.min(a.max[2], b.max[2]),
25
+ ];
26
+ // Ensure valid bounds (max >= min)
27
+ return {
28
+ min: [
29
+ Math.min(min[0], max[0]),
30
+ Math.min(min[1], max[1]),
31
+ Math.min(min[2], max[2]),
32
+ ],
33
+ max: [
34
+ Math.max(min[0], max[0]),
35
+ Math.max(min[1], max[1]),
36
+ Math.max(min[2], max[2]),
37
+ ],
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Compute the world-space axis-aligned bounding box of the visible region
43
+ * in a 3D render view.
44
+ *
45
+ * NiiVue uses an orthographic projection whose extents depend on
46
+ * `volScaleMultiplier`, `renderAzimuth`, and `renderElevation`.
47
+ * We build the 8 corners of the ortho frustum, rotate them by the inverse
48
+ * of the view rotation, and take the AABB.
49
+ *
50
+ * @param nv - The Niivue instance
51
+ * @param volumeBounds - Full volume bounds to intersect with
52
+ * @returns Viewport bounds in world space, intersected with volumeBounds
53
+ */
54
+ export function computeViewportBounds3D(
55
+ nv: Niivue,
56
+ volumeBounds: VolumeBounds,
57
+ ): VolumeBounds {
58
+ // Get scene extents: [min, max, range]
59
+ const extents = nv.sceneExtentsMinMax(true);
60
+ const mn = extents[0]; // vec3
61
+ const mx = extents[1]; // vec3
62
+ const range = extents[2]; // vec3
63
+
64
+ // Pivot = center of scene
65
+ const pivotX = (mn[0] + mx[0]) * 0.5;
66
+ const pivotY = (mn[1] + mx[1]) * 0.5;
67
+ const pivotZ = (mn[2] + mx[2]) * 0.5;
68
+
69
+ // furthestFromPivot = half-diagonal of bounding box
70
+ const furthest = Math.sqrt(
71
+ range[0] * range[0] + range[1] * range[1] + range[2] * range[2],
72
+ ) * 0.5;
73
+
74
+ // NiiVue's orthographic scale (matches calculateMvpMatrix)
75
+ const scale = (0.8 * furthest) / (nv.scene.volScaleMultiplier || 1);
76
+
77
+ // Canvas aspect ratio
78
+ const canvas = nv.canvas;
79
+ const canvasW = canvas?.width ?? 1;
80
+ const canvasH = canvas?.height ?? 1;
81
+ const whratio = canvasW / canvasH;
82
+
83
+ // Ortho extents in view space (before rotation)
84
+ let halfW: number, halfH: number;
85
+ if (whratio < 1) {
86
+ // Portrait
87
+ halfW = scale;
88
+ halfH = scale / whratio;
89
+ } else {
90
+ // Landscape
91
+ halfW = scale * whratio;
92
+ halfH = scale;
93
+ }
94
+ // For viewport-aware resolution, we need the world-space extent that is
95
+ // visible on screen. Rather than rotating a full 3D frustum box (whose depth
96
+ // dominates the AABB after rotation), we compute the extent differently:
97
+ //
98
+ // Project the view-space axes onto world space and accumulate the half-extents
99
+ // contributed by each view axis. The view X axis (with extent halfW) and view
100
+ // Y axis (with extent halfH) determine what is visible on screen. The depth
101
+ // axis does NOT constrain visibility — we see through the full depth of the
102
+ // volume — so we include the full volume extent along the view Z axis.
103
+
104
+ // Build the inverse of NiiVue's view rotation.
105
+ // NiiVue applies: rotateX(270 - elevation) then rotateZ(azimuth - 180)
106
+ // Also mirrors X (modelMatrix[0] = -1).
107
+ // We need the inverse rotation to go from view space back to world space.
108
+ const azimuth = nv.scene.renderAzimuth ?? 0;
109
+ const elevation = nv.scene.renderElevation ?? 0;
110
+ const azRad = ((azimuth - 180) * Math.PI) / 180;
111
+ const elRad = ((270 - elevation) * Math.PI) / 180;
112
+
113
+ const cosAz = Math.cos(azRad);
114
+ const sinAz = Math.sin(azRad);
115
+ const cosEl = Math.cos(elRad);
116
+ const sinEl = Math.sin(elRad);
117
+
118
+ // Compute inverse rotation of unit view-space axes to world space.
119
+ // Inverse: un-mirror X, then rotateZ(-azRad), then rotateX(-elRad).
120
+ //
121
+ // View X axis (1, 0, 0) after un-mirror: (-1, 0, 0)
122
+ const viewXinWorld: [number, number, number] = [
123
+ -cosAz, // wx
124
+ sinAz * cosEl, // wy
125
+ -sinAz * sinEl, // wz
126
+ ];
127
+ // View Y axis (0, 1, 0) — no mirror effect
128
+ const viewYinWorld: [number, number, number] = [
129
+ sinAz, // wx
130
+ cosAz * cosEl, // wy
131
+ -cosAz * sinEl, // wz
132
+ ];
133
+ // View Z axis (0, 0, 1) — no mirror effect
134
+ const viewZinWorld: [number, number, number] = [
135
+ 0, // wx
136
+ sinEl, // wy
137
+ cosEl, // wz
138
+ ];
139
+
140
+ // For each world axis, the visible half-extent is:
141
+ // halfW * |viewXinWorld[axis]| + halfH * |viewYinWorld[axis]|
142
+ // + furthest * |viewZinWorld[axis]| (full volume depth along view Z)
143
+ const worldHalfExtent: [number, number, number] = [0, 0, 0];
144
+ for (let axis = 0; axis < 3; axis++) {
145
+ worldHalfExtent[axis] = halfW * Math.abs(viewXinWorld[axis]) +
146
+ halfH * Math.abs(viewYinWorld[axis]) +
147
+ furthest * Math.abs(viewZinWorld[axis]);
148
+ }
149
+
150
+ const frustumBounds: VolumeBounds = {
151
+ min: [
152
+ pivotX - worldHalfExtent[0],
153
+ pivotY - worldHalfExtent[1],
154
+ pivotZ - worldHalfExtent[2],
155
+ ],
156
+ max: [
157
+ pivotX + worldHalfExtent[0],
158
+ pivotY + worldHalfExtent[1],
159
+ pivotZ + worldHalfExtent[2],
160
+ ],
161
+ };
162
+
163
+ return intersectBounds(frustumBounds, volumeBounds);
164
+ }
165
+
166
+ /**
167
+ * Compute the world-space bounding box of the visible region in a 2D slice
168
+ * view, accounting for pan and zoom.
169
+ *
170
+ * NiiVue's 2D slice renderer applies pan (`pan2Dxyzmm[0..2]`) and zoom
171
+ * (`pan2Dxyzmm[3]`) to the base field of view. We replicate this math to
172
+ * determine what mm range is visible on screen.
173
+ *
174
+ * @param nv - The Niivue instance
175
+ * @param sliceType - Current 2D slice type (AXIAL, CORONAL, SAGITTAL)
176
+ * @param volumeBounds - Full volume bounds to intersect with
177
+ * @param normalizationScale - If the NVImage affine was normalized (multiplied
178
+ * by 1/maxVoxelSize to avoid NiiVue precision issues), pass that scale here
179
+ * so we can convert the NiiVue mm-space FOV back to physical world
180
+ * coordinates. Pass 1.0 if no normalization was applied.
181
+ * @returns Viewport bounds in world space, intersected with volumeBounds
182
+ */
183
+ export function computeViewportBounds2D(
184
+ nv: Niivue,
185
+ sliceType: SLICE_TYPE,
186
+ volumeBounds: VolumeBounds,
187
+ normalizationScale: number = 1.0,
188
+ ): VolumeBounds {
189
+ // Compute the base field of view from the FULL volume bounds (in normalized
190
+ // mm space), then swizzle to screen axes for this slice orientation.
191
+ //
192
+ // IMPORTANT: We intentionally do NOT use nv.screenFieldOfViewExtendedMM()
193
+ // because that returns the extents of the *currently loaded* NVImage (the
194
+ // slab). After a viewport-aware reload shrinks the slab, the next call to
195
+ // screenFieldOfViewExtendedMM() would return a smaller FOV, creating a
196
+ // feedback loop that progressively shrinks the slab to nothing.
197
+ //
198
+ // Instead, we derive the base FOV from the constant full-volume bounds,
199
+ // scaled to normalized mm space (matching the slab NVImage's affine).
200
+ // We then apply the same swizzle that NiiVue uses, giving us a stable
201
+ // base FOV that doesn't depend on the current slab geometry.
202
+ const normMin: [number, number, number] = [
203
+ volumeBounds.min[0] * normalizationScale,
204
+ volumeBounds.min[1] * normalizationScale,
205
+ volumeBounds.min[2] * normalizationScale,
206
+ ];
207
+ const normMax: [number, number, number] = [
208
+ volumeBounds.max[0] * normalizationScale,
209
+ volumeBounds.max[1] * normalizationScale,
210
+ volumeBounds.max[2] * normalizationScale,
211
+ ];
212
+
213
+ // Swizzle to screen axes (same mapping as NiiVue's swizzleVec3MM):
214
+ // AXIAL: screen X = mm X, screen Y = mm Y
215
+ // CORONAL: screen X = mm X, screen Y = mm Z
216
+ // SAGITTAL: screen X = mm Y, screen Y = mm Z
217
+ let mnMM0: number, mxMM0: number, mnMM1: number, mxMM1: number;
218
+ switch (sliceType) {
219
+ case SLICE_TYPE.CORONAL:
220
+ mnMM0 = normMin[0];
221
+ mxMM0 = normMax[0]; // screen X = mm X
222
+ mnMM1 = normMin[2];
223
+ mxMM1 = normMax[2]; // screen Y = mm Z
224
+ break;
225
+ case SLICE_TYPE.SAGITTAL:
226
+ mnMM0 = normMin[1];
227
+ mxMM0 = normMax[1]; // screen X = mm Y
228
+ mnMM1 = normMin[2];
229
+ mxMM1 = normMax[2]; // screen Y = mm Z
230
+ break;
231
+ default: // AXIAL
232
+ mnMM0 = normMin[0];
233
+ mxMM0 = normMax[0]; // screen X = mm X
234
+ mnMM1 = normMin[1];
235
+ mxMM1 = normMax[1]; // screen Y = mm Y
236
+ break;
237
+ }
238
+
239
+ // Account for canvas aspect ratio stretching (matches draw2DMain logic)
240
+ // NiiVue stretches the FOV to fill the canvas while preserving aspect ratio
241
+ const canvas = nv.canvas;
242
+ if (canvas) {
243
+ const canvasW = canvas.width || 1;
244
+ const canvasH = canvas.height || 1;
245
+ const fovW = Math.abs(mxMM0 - mnMM0);
246
+ const fovH = Math.abs(mxMM1 - mnMM1);
247
+ if (fovW > 0 && fovH > 0) {
248
+ const canvasAspect = canvasW / canvasH;
249
+ const fovAspect = fovW / fovH;
250
+ if (canvasAspect > fovAspect) {
251
+ // Canvas is wider than FOV: expand X
252
+ const midX = (mnMM0 + mxMM0) * 0.5;
253
+ const newHalfW = (fovH * canvasAspect) * 0.5;
254
+ mnMM0 = midX - newHalfW;
255
+ mxMM0 = midX + newHalfW;
256
+ } else {
257
+ // Canvas is taller than FOV: expand Y
258
+ const midY = (mnMM1 + mxMM1) * 0.5;
259
+ const newHalfH = (fovW / canvasAspect) * 0.5;
260
+ mnMM1 = midY - newHalfH;
261
+ mxMM1 = midY + newHalfH;
262
+ }
263
+ }
264
+ }
265
+
266
+ // Apply pan and zoom (matching NiiVue's draw2DMain logic)
267
+ const pan = nv.scene.pan2Dxyzmm; // vec4: [panX, panY, panZ, zoom]
268
+ // Swizzle the pan vector to match the current orientation
269
+ const panSwizzled = nv.swizzleVec3MM(
270
+ [pan[0], pan[1], pan[2]] as unknown as import("gl-matrix").vec3,
271
+ sliceType,
272
+ );
273
+ const zoom = pan[3] || 1;
274
+
275
+ // Apply pan: shift visible window
276
+ mnMM0 -= panSwizzled[0];
277
+ mxMM0 -= panSwizzled[0];
278
+ mnMM1 -= panSwizzled[1];
279
+ mxMM1 -= panSwizzled[1];
280
+
281
+ // Apply zoom: divide by zoom factor (zoom > 1 = zoomed in = smaller FOV)
282
+ mnMM0 /= zoom;
283
+ mxMM0 /= zoom;
284
+ mnMM1 /= zoom;
285
+ mxMM1 /= zoom;
286
+
287
+ // Convert from NiiVue's mm space back to physical world coordinates.
288
+ // If the slab affine was normalized (multiplied by normalizationScale),
289
+ // NiiVue's mm values are world * normalizationScale. Dividing by the
290
+ // normalization scale recovers physical coordinates.
291
+ if (normalizationScale !== 1.0 && normalizationScale > 0) {
292
+ const invNorm = 1.0 / normalizationScale;
293
+ mnMM0 *= invNorm;
294
+ mxMM0 *= invNorm;
295
+ mnMM1 *= invNorm;
296
+ mxMM1 *= invNorm;
297
+ }
298
+
299
+ // Now un-swizzle back to RAS world coordinates.
300
+ // The swizzle maps depend on the slice orientation:
301
+ // AXIAL: screen X = R/L (world X), screen Y = A/P (world Y)
302
+ // CORONAL: screen X = R/L (world X), screen Y = S/I (world Z)
303
+ // SAGITTAL: screen X = A/P (world Y), screen Y = S/I (world Z)
304
+ //
305
+ // The orthogonal axis (depth) is left as full volume extent.
306
+ const result: VolumeBounds = {
307
+ min: [...volumeBounds.min],
308
+ max: [...volumeBounds.max],
309
+ };
310
+
311
+ // Ensure min < max for each swizzled axis
312
+ const visMin0 = Math.min(mnMM0, mxMM0);
313
+ const visMax0 = Math.max(mnMM0, mxMM0);
314
+ const visMin1 = Math.min(mnMM1, mxMM1);
315
+ const visMax1 = Math.max(mnMM1, mxMM1);
316
+
317
+ switch (sliceType) {
318
+ case SLICE_TYPE.AXIAL:
319
+ // screen X = world X (R/L), screen Y = world Y (A/P)
320
+ result.min[0] = visMin0;
321
+ result.max[0] = visMax0;
322
+ result.min[1] = visMin1;
323
+ result.max[1] = visMax1;
324
+ // Z (S/I) = full extent (orthogonal axis)
325
+ break;
326
+ case SLICE_TYPE.CORONAL:
327
+ // screen X = world X (R/L), screen Y = world Z (S/I)
328
+ result.min[0] = visMin0;
329
+ result.max[0] = visMax0;
330
+ result.min[2] = visMin1;
331
+ result.max[2] = visMax1;
332
+ // Y (A/P) = full extent (orthogonal axis)
333
+ break;
334
+ case SLICE_TYPE.SAGITTAL:
335
+ // screen X = world Y (A/P), screen Y = world Z (S/I)
336
+ result.min[1] = visMin0;
337
+ result.max[1] = visMax0;
338
+ result.min[2] = visMin1;
339
+ result.max[2] = visMax1;
340
+ // X (R/L) = full extent (orthogonal axis)
341
+ break;
342
+ }
343
+
344
+ return intersectBounds(result, volumeBounds);
345
+ }
346
+
347
+ /**
348
+ * Check if two VolumeBounds are approximately equal.
349
+ *
350
+ * @param a - First bounds
351
+ * @param b - Second bounds
352
+ * @param tolerance - Relative tolerance (default: 0.01 = 1%)
353
+ * @returns True if bounds are within tolerance
354
+ */
355
+ export function boundsApproxEqual(
356
+ a: VolumeBounds,
357
+ b: VolumeBounds,
358
+ tolerance: number = 0.01,
359
+ ): boolean {
360
+ for (let i = 0; i < 3; i++) {
361
+ const rangeA = a.max[i] - a.min[i];
362
+ const rangeB = b.max[i] - b.min[i];
363
+ const maxRange = Math.max(Math.abs(rangeA), Math.abs(rangeB), 1e-10);
364
+
365
+ if (Math.abs(a.min[i] - b.min[i]) / maxRange > tolerance) return false;
366
+ if (Math.abs(a.max[i] - b.max[i]) / maxRange > tolerance) return false;
367
+ }
368
+ return true;
369
+ }
package/src/events.ts ADDED
@@ -0,0 +1,146 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { SLICE_TYPE } from "@niivue/niivue";
5
+ import type { ClipPlanes } from "./types.js";
6
+
7
+ /**
8
+ * Identifies what triggered a volume population.
9
+ * Extensible for future triggers (pan, zoom, etc.)
10
+ */
11
+ export type PopulateTrigger =
12
+ | "initial" // First load or reload with new settings
13
+ | "clipPlanesChanged" // Clip planes were modified
14
+ | "sliceChanged" // Slice position changed (slab reload)
15
+ | "viewportChanged"; // Viewport pan/zoom/rotation changed
16
+
17
+ /**
18
+ * Type-safe event map for OMEZarrNVImage events.
19
+ * Maps event names to their detail types.
20
+ *
21
+ * Uses the browser-native EventTarget API pattern, following the same
22
+ * conventions as NiiVue's event system (see niivue/niivue#1530).
23
+ *
24
+ * @example
25
+ * ```typescript
26
+ * // Type-safe event listening
27
+ * image.addEventListener('resolutionChange', (event) => {
28
+ * console.log('Resolution changed:', event.detail.currentLevel);
29
+ * });
30
+ *
31
+ * // One-time listener
32
+ * image.addEventListener('loadingComplete', (event) => {
33
+ * console.log('Loaded level:', event.detail.levelIndex);
34
+ * }, { once: true });
35
+ *
36
+ * // Using AbortController to remove multiple listeners
37
+ * const controller = new AbortController();
38
+ * image.addEventListener('loadingStart', handler1, { signal: controller.signal });
39
+ * image.addEventListener('loadingComplete', handler2, { signal: controller.signal });
40
+ * controller.abort(); // removes both listeners
41
+ * ```
42
+ */
43
+ export interface OMEZarrNVImageEventMap {
44
+ /** Fired when loading starts for a resolution level */
45
+ loadingStart: {
46
+ levelIndex: number;
47
+ trigger: PopulateTrigger;
48
+ };
49
+
50
+ /** Fired when loading completes for a resolution level */
51
+ loadingComplete: {
52
+ levelIndex: number;
53
+ trigger: PopulateTrigger;
54
+ };
55
+
56
+ /**
57
+ * Fired when resolution level changes.
58
+ * This happens during progressive loading or when clip planes
59
+ * cause a resolution change.
60
+ */
61
+ resolutionChange: {
62
+ currentLevel: number;
63
+ targetLevel: number;
64
+ previousLevel: number;
65
+ trigger: PopulateTrigger;
66
+ };
67
+
68
+ /**
69
+ * Fired when clip planes are updated (after debounce).
70
+ * This is emitted after the debounce delay, not on every slider movement.
71
+ */
72
+ clipPlanesChange: { clipPlanes: ClipPlanes };
73
+
74
+ /**
75
+ * Fired when populateVolume() completes and no more requests are queued.
76
+ * This is the final event after all loading is done.
77
+ */
78
+ populateComplete: {
79
+ currentLevel: number;
80
+ targetLevel: number;
81
+ trigger: PopulateTrigger;
82
+ };
83
+
84
+ /**
85
+ * Fired when a queued load request is replaced by a newer one.
86
+ * This only fires when a pending request is overwritten, not when
87
+ * the first request is queued.
88
+ */
89
+ loadingSkipped: {
90
+ reason: "queued-replaced";
91
+ trigger: PopulateTrigger;
92
+ };
93
+
94
+ /**
95
+ * Fired when a slab (2D slice buffer) finishes loading.
96
+ * This event is specific to slab-based loading for 2D slice views.
97
+ */
98
+ slabLoadingComplete: {
99
+ sliceType: SLICE_TYPE;
100
+ levelIndex: number;
101
+ slabStart: number;
102
+ slabEnd: number;
103
+ trigger: PopulateTrigger;
104
+ };
105
+
106
+ /**
107
+ * Fired when a slab starts loading.
108
+ */
109
+ slabLoadingStart: {
110
+ sliceType: SLICE_TYPE;
111
+ levelIndex: number;
112
+ trigger: PopulateTrigger;
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Type-safe event class for OMEZarrNVImage events.
118
+ * Extends CustomEvent with typed detail property.
119
+ */
120
+ export class OMEZarrNVImageEvent<
121
+ K extends keyof OMEZarrNVImageEventMap,
122
+ > extends CustomEvent<OMEZarrNVImageEventMap[K]> {
123
+ constructor(type: K, detail: OMEZarrNVImageEventMap[K]) {
124
+ super(type, { detail });
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Type-safe event listener for OMEZarrNVImage events.
130
+ * Listeners can be synchronous or asynchronous.
131
+ */
132
+ export type OMEZarrNVImageEventListener<
133
+ K extends keyof OMEZarrNVImageEventMap,
134
+ > = (event: OMEZarrNVImageEvent<K>) => void | Promise<void>;
135
+
136
+ /**
137
+ * Options for addEventListener/removeEventListener.
138
+ * Supports all standard EventTarget options including:
139
+ * - capture: boolean - Use capture phase
140
+ * - once: boolean - Remove listener after first invocation
141
+ * - passive: boolean - Listener will never call preventDefault()
142
+ * - signal: AbortSignal - Remove listener when signal is aborted
143
+ */
144
+ export type OMEZarrNVImageEventListenerOptions =
145
+ | boolean
146
+ | AddEventListenerOptions;
package/src/index.ts ADDED
@@ -0,0 +1,153 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * @fideus-labs/fidnii
6
+ *
7
+ * Render OME-Zarr images in NiiVue with progressive multi-resolution loading.
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * import { Niivue } from '@niivue/niivue';
12
+ * import { fromNgffZarr } from '@fideus-labs/ngff-zarr';
13
+ * import { OMEZarrNVImage } from '@fideus-labs/fidnii';
14
+ *
15
+ * const nv = new Niivue();
16
+ * await nv.attachToCanvas(document.getElementById('canvas'));
17
+ *
18
+ * const multiscales = await fromNgffZarr('/path/to/data.ome.zarr');
19
+ *
20
+ * // Image is automatically added to NiiVue and loads progressively
21
+ * const image = await OMEZarrNVImage.create({ multiscales, niivue: nv });
22
+ *
23
+ * // Listen for loading complete if needed
24
+ * image.addEventListener('populateComplete', () => console.log('Loaded!'));
25
+ *
26
+ * // For manual control, use autoLoad: false
27
+ * // const image = await OMEZarrNVImage.create({
28
+ * // multiscales,
29
+ * // niivue: nv,
30
+ * // autoLoad: false,
31
+ * // });
32
+ * // nv.addVolume(image);
33
+ * // await image.populateVolume();
34
+ * ```
35
+ */
36
+
37
+ // Main class
38
+ export { OMEZarrNVImage } from "./OMEZarrNVImage.js";
39
+
40
+ // Types
41
+ export type {
42
+ AttachedNiivueState,
43
+ ChunkAlignedRegion,
44
+ ChunkCache,
45
+ ClipPlane,
46
+ ClipPlanes,
47
+ OMEZarrNVImageOptions,
48
+ PixelRegion,
49
+ RegionFetchResult,
50
+ ResolutionSelection,
51
+ SlabBufferState,
52
+ SlabSliceType,
53
+ TypedArray,
54
+ VolumeBounds,
55
+ ZarrDtype,
56
+ } from "./types.js";
57
+
58
+ // Re-export SLICE_TYPE from types (which re-exports from niivue)
59
+ export { SLICE_TYPE } from "./types.js";
60
+
61
+ // Clip planes utilities
62
+ export {
63
+ alignToChunks,
64
+ azimuthElevationToNormal,
65
+ calculateNiivueDepth,
66
+ clipPlanesToBoundingBox,
67
+ clipPlanesToNiivue,
68
+ clipPlanesToPixelRegion,
69
+ clipPlaneToNiivue,
70
+ createAxisAlignedClipPlane,
71
+ createClipPlane,
72
+ createDefaultClipPlanes,
73
+ getVolumeBoundsFromMultiscales,
74
+ isInsideClipPlanes,
75
+ MAX_CLIP_PLANES,
76
+ normalizeVector,
77
+ normalToAzimuthElevation,
78
+ pointToPlaneDistance,
79
+ validateClipPlanes,
80
+ } from "./ClipPlanes.js";
81
+
82
+ // Resolution selector utilities
83
+ export {
84
+ alignRegionToChunks,
85
+ calculateUpsampleFactor,
86
+ getChunkShape,
87
+ getFullVolumeDimensions,
88
+ getMiddleResolutionIndex,
89
+ getVolumeShape,
90
+ select2DResolution,
91
+ selectResolution,
92
+ } from "./ResolutionSelector.js";
93
+
94
+ export type { OrthogonalAxis } from "./ResolutionSelector.js";
95
+
96
+ // Buffer manager
97
+ export { BufferManager } from "./BufferManager.js";
98
+
99
+ // Region coalescer
100
+ export { RegionCoalescer } from "./RegionCoalescer.js";
101
+
102
+ // Coordinate utilities
103
+ export {
104
+ ceilPixelCoord,
105
+ clampPixelCoord,
106
+ floorPixelCoord,
107
+ normalizedToWorld,
108
+ pixelToWorld,
109
+ pixelToWorldAffine,
110
+ roundPixelCoord,
111
+ worldToNormalized,
112
+ worldToPixel,
113
+ worldToPixelAffine,
114
+ } from "./utils/coordinates.js";
115
+
116
+ // Affine utilities
117
+ export {
118
+ affineToNiftiSrows,
119
+ calculateWorldBounds,
120
+ createAffineFromNgffImage,
121
+ createAffineFromOMEZarr,
122
+ getPixelDimensions,
123
+ updateAffineForRegion,
124
+ } from "./utils/affine.js";
125
+
126
+ // Type utilities
127
+ export {
128
+ getBytesPerPixel,
129
+ getNiftiDataType,
130
+ getTypedArrayConstructor,
131
+ NiftiDataType,
132
+ parseZarritaDtype,
133
+ } from "./types.js";
134
+
135
+ // Worker pool lifecycle (re-exported from ngff-zarr)
136
+ export { terminateWorkerPool } from "@fideus-labs/ngff-zarr/browser";
137
+
138
+ // Viewport bounds utilities
139
+ export {
140
+ boundsApproxEqual,
141
+ computeViewportBounds2D,
142
+ computeViewportBounds3D,
143
+ intersectBounds,
144
+ } from "./ViewportBounds.js";
145
+
146
+ // Event system (browser-native EventTarget API)
147
+ export { OMEZarrNVImageEvent } from "./events.js";
148
+ export type {
149
+ OMEZarrNVImageEventListener,
150
+ OMEZarrNVImageEventListenerOptions,
151
+ OMEZarrNVImageEventMap,
152
+ PopulateTrigger,
153
+ } from "./events.js";