@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.
- package/LICENSE.txt +9 -0
- package/README.md +180 -0
- package/dist/BufferManager.d.ts +86 -0
- package/dist/BufferManager.d.ts.map +1 -0
- package/dist/BufferManager.js +146 -0
- package/dist/BufferManager.js.map +1 -0
- package/dist/ClipPlanes.d.ts +180 -0
- package/dist/ClipPlanes.d.ts.map +1 -0
- package/dist/ClipPlanes.js +513 -0
- package/dist/ClipPlanes.js.map +1 -0
- package/dist/OMEZarrNVImage.d.ts +545 -0
- package/dist/OMEZarrNVImage.d.ts.map +1 -0
- package/dist/OMEZarrNVImage.js +1799 -0
- package/dist/OMEZarrNVImage.js.map +1 -0
- package/dist/RegionCoalescer.d.ts +75 -0
- package/dist/RegionCoalescer.d.ts.map +1 -0
- package/dist/RegionCoalescer.js +151 -0
- package/dist/RegionCoalescer.js.map +1 -0
- package/dist/ResolutionSelector.d.ts +88 -0
- package/dist/ResolutionSelector.d.ts.map +1 -0
- package/dist/ResolutionSelector.js +224 -0
- package/dist/ResolutionSelector.js.map +1 -0
- package/dist/ViewportBounds.d.ts +50 -0
- package/dist/ViewportBounds.d.ts.map +1 -0
- package/dist/ViewportBounds.js +325 -0
- package/dist/ViewportBounds.js.map +1 -0
- package/dist/events.d.ts +122 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +12 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +59 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +273 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +126 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/affine.d.ts +72 -0
- package/dist/utils/affine.d.ts.map +1 -0
- package/dist/utils/affine.js +173 -0
- package/dist/utils/affine.js.map +1 -0
- package/dist/utils/coordinates.d.ts +80 -0
- package/dist/utils/coordinates.d.ts.map +1 -0
- package/dist/utils/coordinates.js +207 -0
- package/dist/utils/coordinates.js.map +1 -0
- package/package.json +61 -0
- package/src/BufferManager.ts +176 -0
- package/src/ClipPlanes.ts +640 -0
- package/src/OMEZarrNVImage.ts +2286 -0
- package/src/RegionCoalescer.ts +217 -0
- package/src/ResolutionSelector.ts +325 -0
- package/src/ViewportBounds.ts +369 -0
- package/src/events.ts +146 -0
- package/src/index.ts +153 -0
- package/src/types.ts +429 -0
- package/src/utils/affine.ts +218 -0
- 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";
|