@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
package/src/types.ts ADDED
@@ -0,0 +1,429 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Multiscales } from "@fideus-labs/ngff-zarr";
5
+ import type { Niivue, NVImage } from "@niivue/niivue";
6
+ import { SLICE_TYPE } from "@niivue/niivue";
7
+ import type { BufferManager } from "./BufferManager.js";
8
+ import type { PopulateTrigger } from "./events.js";
9
+
10
+ /**
11
+ * A single clip plane defined by a point and normal vector.
12
+ * The plane equation is: normal · (P - point) = 0
13
+ * Points on the positive side of the normal are kept (visible).
14
+ */
15
+ export interface ClipPlane {
16
+ /** A point on the plane (center of volume projected to plane) [x, y, z] in world coordinates */
17
+ point: [number, number, number];
18
+ /** Unit normal vector pointing toward visible region [x, y, z] */
19
+ normal: [number, number, number];
20
+ }
21
+
22
+ /**
23
+ * Collection of clip planes that define the visible region.
24
+ * Each plane clips away the half-space on the negative side of its normal.
25
+ * Maximum 6 planes (NiiVue limit). Empty array = full volume visible.
26
+ */
27
+ export type ClipPlanes = ClipPlane[];
28
+
29
+ /**
30
+ * Volume bounds in world space.
31
+ */
32
+ export interface VolumeBounds {
33
+ min: [number, number, number];
34
+ max: [number, number, number];
35
+ }
36
+
37
+ /**
38
+ * A pixel region in array indices.
39
+ * Coordinates are in [z, y, x] order to match OME-Zarr conventions.
40
+ */
41
+ export interface PixelRegion {
42
+ /** Start indices [z, y, x] (inclusive) */
43
+ start: [number, number, number];
44
+ /** End indices [z, y, x] (exclusive) */
45
+ end: [number, number, number];
46
+ }
47
+
48
+ /**
49
+ * A pixel region that has been aligned to chunk boundaries.
50
+ */
51
+ export interface ChunkAlignedRegion extends PixelRegion {
52
+ /** Chunk-aligned start indices [z, y, x] */
53
+ chunkAlignedStart: [number, number, number];
54
+ /** Chunk-aligned end indices [z, y, x] */
55
+ chunkAlignedEnd: [number, number, number];
56
+ /** True if the original region didn't align with chunk boundaries */
57
+ needsClipping: boolean;
58
+ }
59
+
60
+ /**
61
+ * Result of selecting an appropriate resolution level.
62
+ */
63
+ export interface ResolutionSelection {
64
+ /** Index into multiscales.images array */
65
+ levelIndex: number;
66
+ /** Dimensions of the buffer [z, y, x] */
67
+ dimensions: [number, number, number];
68
+ /** Total pixel count */
69
+ pixelCount: number;
70
+ }
71
+
72
+ /**
73
+ * Interface for a decoded-chunk cache, compatible with `Map`.
74
+ *
75
+ * Caches decoded chunks keyed by a string combining the store instance,
76
+ * array path, and chunk coordinates. This avoids redundant decompression
77
+ * when accessing overlapping selections or making repeated calls to the
78
+ * same data.
79
+ *
80
+ * Any object with `get(key)` and `set(key, value)` works — a plain `Map`
81
+ * is the simplest option. For bounded memory use an LRU cache such as
82
+ * `lru-cache`.
83
+ *
84
+ * @example
85
+ * ```ts
86
+ * // Use a plain Map (unbounded)
87
+ * const cache = new Map()
88
+ *
89
+ * // Use lru-cache (bounded)
90
+ * import { LRUCache } from 'lru-cache'
91
+ * const cache = new LRUCache({ max: 200 })
92
+ * ```
93
+ */
94
+ export interface ChunkCache {
95
+ /** Look up a cached decoded chunk by key. */
96
+ get(key: string): unknown | undefined;
97
+ /** Store a decoded chunk under the given key. */
98
+ set(key: string, value: unknown): void;
99
+ }
100
+
101
+ /**
102
+ * Options for creating an OMEZarrNVImage.
103
+ */
104
+ export interface OMEZarrNVImageOptions {
105
+ /** The OME-Zarr multiscales data */
106
+ multiscales: Multiscales;
107
+ /** Reference to the NiiVue instance for rendering updates */
108
+ niivue: Niivue;
109
+ /** Maximum number of pixels to use (default: 50,000,000) */
110
+ maxPixels?: number;
111
+ /** Debounce delay for clip plane data refetch in milliseconds (default: 300) */
112
+ clipPlaneDebounceMs?: number;
113
+ /**
114
+ * Automatically add to NiiVue and start progressive loading (default: true).
115
+ * Set to false to manually control when populateVolume() is called.
116
+ * Listen to 'populateComplete' event to know when loading finishes.
117
+ */
118
+ autoLoad?: boolean;
119
+ /**
120
+ * Maximum 3D render zoom level for scroll-wheel zoom (default: 10.0).
121
+ * NiiVue's built-in 3D zoom is hardcoded to [0.5, 2.0]. This option
122
+ * overrides the scroll-wheel zoom handler to allow zooming beyond that limit.
123
+ */
124
+ max3DZoom?: number;
125
+ /**
126
+ * Minimum 3D render zoom level for scroll-wheel zoom (default: 0.3).
127
+ * @see max3DZoom
128
+ */
129
+ min3DZoom?: number;
130
+ /**
131
+ * Enable viewport-aware resolution selection (default: true).
132
+ * When enabled, zoom/pan interactions constrain the fetch region to the
133
+ * visible viewport, allowing higher resolution within the same maxPixels budget.
134
+ */
135
+ viewportAware?: boolean;
136
+ /**
137
+ * Maximum number of decoded-chunk cache entries (default: 200).
138
+ *
139
+ * Fidnii creates an LRU cache that avoids redundant chunk decompression
140
+ * on repeated or overlapping reads (e.g. clip plane adjustments, viewport
141
+ * panning, progressive resolution loading).
142
+ *
143
+ * Set to `0` to disable caching entirely.
144
+ */
145
+ maxCacheEntries?: number;
146
+ /**
147
+ * Optional pre-built decoded-chunk cache. When provided, overrides the
148
+ * internal LRU cache created from `maxCacheEntries`.
149
+ *
150
+ * Any object with `get(key)` / `set(key, value)` works — a plain `Map`
151
+ * or any LRU cache implementing the same interface.
152
+ *
153
+ * @see {@link ChunkCache}
154
+ */
155
+ cache?: ChunkCache;
156
+ }
157
+
158
+ /**
159
+ * Result of fetching a region from the zarr store.
160
+ */
161
+ export interface RegionFetchResult {
162
+ /** The pixel data as a typed array */
163
+ data: TypedArray;
164
+ /** Shape of the fetched data [z, y, x] */
165
+ shape: number[];
166
+ /** Stride of the fetched data */
167
+ stride: number[];
168
+ }
169
+
170
+ /**
171
+ * Supported zarr data types.
172
+ */
173
+ export type ZarrDtype =
174
+ | "uint8"
175
+ | "uint16"
176
+ | "uint32"
177
+ | "int8"
178
+ | "int16"
179
+ | "int32"
180
+ | "float32"
181
+ | "float64";
182
+
183
+ /**
184
+ * Union of all typed array types we support.
185
+ */
186
+ export type TypedArray =
187
+ | Uint8Array
188
+ | Uint16Array
189
+ | Uint32Array
190
+ | Int8Array
191
+ | Int16Array
192
+ | Int32Array
193
+ | Float32Array
194
+ | Float64Array;
195
+
196
+ /**
197
+ * Typed arrays supported by NiiVue.
198
+ * NiiVue only supports a subset of typed arrays.
199
+ */
200
+ export type NiiVueTypedArray =
201
+ | Uint8Array
202
+ | Uint16Array
203
+ | Int16Array
204
+ | Float32Array
205
+ | Float64Array;
206
+
207
+ // Re-export SLICE_TYPE for convenience
208
+ export { SLICE_TYPE };
209
+
210
+ /**
211
+ * The 2D slice types that use slab-based loading.
212
+ * These are the Niivue slice types that show a single 2D plane.
213
+ */
214
+ export type SlabSliceType =
215
+ | typeof SLICE_TYPE.AXIAL
216
+ | typeof SLICE_TYPE.CORONAL
217
+ | typeof SLICE_TYPE.SAGITTAL;
218
+
219
+ /**
220
+ * State for a per-slice-type slab buffer.
221
+ *
222
+ * Each 2D slice view (axial, coronal, sagittal) gets its own NVImage buffer
223
+ * loaded with a slab (one chunk thick in the orthogonal direction) at the
224
+ * current slice position.
225
+ */
226
+ export interface SlabBufferState {
227
+ /** The NVImage instance for this slab */
228
+ nvImage: NVImage;
229
+ /** Buffer manager for this slab's pixel data */
230
+ bufferManager: BufferManager;
231
+ /** Current resolution level index for this slab */
232
+ levelIndex: number;
233
+ /** Target resolution level index for this slab */
234
+ targetLevelIndex: number;
235
+ /** Start index of the currently loaded slab in the orthogonal axis (pixel coords at current level) */
236
+ slabStart: number;
237
+ /** End index of the currently loaded slab in the orthogonal axis (pixel coords at current level) */
238
+ slabEnd: number;
239
+ /** Whether this slab is currently loading */
240
+ isLoading: boolean;
241
+ /** Data type of the slab */
242
+ dtype: ZarrDtype;
243
+ /**
244
+ * The affine normalization scale applied to the slab NVImage header.
245
+ * NiiVue mm values = world * normalizationScale.
246
+ * This is 1/maxVoxelSize where maxVoxelSize = max(sx, sy, sz).
247
+ * Used to convert NiiVue 2D FOV coordinates back to physical world coords.
248
+ */
249
+ normalizationScale: number;
250
+ /**
251
+ * Pending reload request queued while this slab was loading.
252
+ * Latest-wins semantics: only the most recent request is kept.
253
+ * Auto-drained when the current load completes.
254
+ */
255
+ pendingReload: {
256
+ worldCoord: [number, number, number];
257
+ trigger: PopulateTrigger;
258
+ } | null;
259
+ }
260
+
261
+ /**
262
+ * State for a Niivue instance attached to an OMEZarrNVImage.
263
+ */
264
+ export interface AttachedNiivueState {
265
+ /** The Niivue instance */
266
+ nv: Niivue;
267
+ /** The current slice type of this NV instance */
268
+ currentSliceType: SLICE_TYPE;
269
+ /** Previous onLocationChange callback (to chain) */
270
+ previousOnLocationChange?: (location: unknown) => void;
271
+ /** Previous onOptsChange callback (to chain) */
272
+ previousOnOptsChange?: (
273
+ propertyName: string,
274
+ newValue: unknown,
275
+ oldValue: unknown,
276
+ ) => void;
277
+ /** Previous onMouseUp callback (to chain, for viewport-aware mode) */
278
+ previousOnMouseUp?: (data: unknown) => void;
279
+ /** Previous onZoom3DChange callback (to chain, for viewport-aware mode) */
280
+ previousOnZoom3DChange?: (zoom: number) => void;
281
+ /** AbortController for viewport-aware event listeners (wheel, etc.) */
282
+ viewportAbortController?: AbortController;
283
+ /** AbortController for the 3D zoom override wheel listener */
284
+ zoomOverrideAbortController?: AbortController;
285
+ }
286
+
287
+ /**
288
+ * Typed array constructor types.
289
+ */
290
+ export type TypedArrayConstructor =
291
+ | Uint8ArrayConstructor
292
+ | Uint16ArrayConstructor
293
+ | Uint32ArrayConstructor
294
+ | Int8ArrayConstructor
295
+ | Int16ArrayConstructor
296
+ | Int32ArrayConstructor
297
+ | Float32ArrayConstructor
298
+ | Float64ArrayConstructor;
299
+
300
+ /**
301
+ * NIfTI data type codes.
302
+ */
303
+ export const NiftiDataType = {
304
+ UINT8: 2,
305
+ INT16: 4,
306
+ INT32: 8,
307
+ FLOAT32: 16,
308
+ FLOAT64: 64,
309
+ INT8: 256,
310
+ UINT16: 512,
311
+ UINT32: 768,
312
+ } as const;
313
+
314
+ export type NiftiDataTypeCode =
315
+ (typeof NiftiDataType)[keyof typeof NiftiDataType];
316
+
317
+ /**
318
+ * Map zarr dtype to typed array constructor.
319
+ */
320
+ export function getTypedArrayConstructor(
321
+ dtype: ZarrDtype,
322
+ ): TypedArrayConstructor {
323
+ switch (dtype) {
324
+ case "uint8":
325
+ return Uint8Array;
326
+ case "uint16":
327
+ return Uint16Array;
328
+ case "uint32":
329
+ return Uint32Array;
330
+ case "int8":
331
+ return Int8Array;
332
+ case "int16":
333
+ return Int16Array;
334
+ case "int32":
335
+ return Int32Array;
336
+ case "float32":
337
+ return Float32Array;
338
+ case "float64":
339
+ return Float64Array;
340
+ default:
341
+ throw new Error(`Unsupported dtype: ${dtype}`);
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Get bytes per pixel for a dtype.
347
+ */
348
+ export function getBytesPerPixel(dtype: ZarrDtype): number {
349
+ switch (dtype) {
350
+ case "uint8":
351
+ case "int8":
352
+ return 1;
353
+ case "uint16":
354
+ case "int16":
355
+ return 2;
356
+ case "uint32":
357
+ case "int32":
358
+ case "float32":
359
+ return 4;
360
+ case "float64":
361
+ return 8;
362
+ default:
363
+ throw new Error(`Unsupported dtype: ${dtype}`);
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Map zarr dtype to NIfTI data type code.
369
+ */
370
+ export function getNiftiDataType(dtype: ZarrDtype): NiftiDataTypeCode {
371
+ switch (dtype) {
372
+ case "uint8":
373
+ return NiftiDataType.UINT8;
374
+ case "uint16":
375
+ return NiftiDataType.UINT16;
376
+ case "uint32":
377
+ return NiftiDataType.UINT32;
378
+ case "int8":
379
+ return NiftiDataType.INT8;
380
+ case "int16":
381
+ return NiftiDataType.INT16;
382
+ case "int32":
383
+ return NiftiDataType.INT32;
384
+ case "float32":
385
+ return NiftiDataType.FLOAT32;
386
+ case "float64":
387
+ return NiftiDataType.FLOAT64;
388
+ default:
389
+ throw new Error(`Unsupported dtype: ${dtype}`);
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Parse a zarrita dtype string to our ZarrDtype.
395
+ * Handles formats like "|u1", "<u2", "<f4", etc.
396
+ */
397
+ export function parseZarritaDtype(dtype: string): ZarrDtype {
398
+ // Remove endianness prefix if present
399
+ const normalized = dtype.replace(/^[|<>]/, "");
400
+
401
+ switch (normalized) {
402
+ case "u1":
403
+ case "uint8":
404
+ return "uint8";
405
+ case "u2":
406
+ case "uint16":
407
+ return "uint16";
408
+ case "u4":
409
+ case "uint32":
410
+ return "uint32";
411
+ case "i1":
412
+ case "int8":
413
+ return "int8";
414
+ case "i2":
415
+ case "int16":
416
+ return "int16";
417
+ case "i4":
418
+ case "int32":
419
+ return "int32";
420
+ case "f4":
421
+ case "float32":
422
+ return "float32";
423
+ case "f8":
424
+ case "float64":
425
+ return "float64";
426
+ default:
427
+ throw new Error(`Unsupported zarrita dtype: ${dtype}`);
428
+ }
429
+ }
@@ -0,0 +1,218 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { mat4 } from "gl-matrix";
5
+ import type { NgffImage } from "@fideus-labs/ngff-zarr";
6
+
7
+ /**
8
+ * Create a 4x4 affine transformation matrix from OME-Zarr scale and translation.
9
+ *
10
+ * The affine matrix transforms from pixel indices to world coordinates.
11
+ * NIfTI uses a column-major 4x4 matrix stored as a flat array of 16 elements.
12
+ *
13
+ * For OME-Zarr, the transformation is:
14
+ * world = scale * pixel + translation
15
+ *
16
+ * The matrix form is:
17
+ * | sx 0 0 tx |
18
+ * | 0 sy 0 ty |
19
+ * | 0 0 sz tz |
20
+ * | 0 0 0 1 |
21
+ *
22
+ * @param scale - Scale factors { x, y, z }
23
+ * @param translation - Translation offsets { x, y, z }
24
+ * @returns 4x4 affine matrix as a flat Float32Array (column-major)
25
+ */
26
+ export function createAffineFromOMEZarr(
27
+ scale: Record<string, number>,
28
+ translation: Record<string, number>,
29
+ ): mat4 {
30
+ const affine = mat4.create();
31
+
32
+ // NIfTI expects the matrix in a specific orientation
33
+ // The affine maps from (i, j, k) voxel indices to (x, y, z) world coordinates
34
+ // For OME-Zarr with [z, y, x] ordering, we need to handle the axis mapping
35
+
36
+ // Extract scale and translation for each axis
37
+ const sx = scale.x ?? scale.X ?? 1;
38
+ const sy = scale.y ?? scale.Y ?? 1;
39
+ const sz = scale.z ?? scale.Z ?? 1;
40
+
41
+ const tx = translation.x ?? translation.X ?? 0;
42
+ const ty = translation.y ?? translation.Y ?? 0;
43
+ const tz = translation.z ?? translation.Z ?? 0;
44
+
45
+ // Build affine matrix
46
+ // NIfTI convention: first index (i) -> x, second (j) -> y, third (k) -> z
47
+ // OME-Zarr stores data as [z, y, x], so we need to account for this
48
+
49
+ // Column 0: x direction (from third array index in [z,y,x])
50
+ affine[0] = sx;
51
+ affine[1] = 0;
52
+ affine[2] = 0;
53
+ affine[3] = 0;
54
+
55
+ // Column 1: y direction (from second array index in [z,y,x])
56
+ affine[4] = 0;
57
+ affine[5] = sy;
58
+ affine[6] = 0;
59
+ affine[7] = 0;
60
+
61
+ // Column 2: z direction (from first array index in [z,y,x])
62
+ affine[8] = 0;
63
+ affine[9] = 0;
64
+ affine[10] = sz;
65
+ affine[11] = 0;
66
+
67
+ // Column 3: translation
68
+ affine[12] = tx;
69
+ affine[13] = ty;
70
+ affine[14] = tz;
71
+ affine[15] = 1;
72
+
73
+ return affine;
74
+ }
75
+
76
+ /**
77
+ * Create an affine matrix from an NgffImage.
78
+ *
79
+ * @param ngffImage - The NgffImage containing scale and translation
80
+ * @returns 4x4 affine matrix
81
+ */
82
+ export function createAffineFromNgffImage(ngffImage: NgffImage): mat4 {
83
+ return createAffineFromOMEZarr(ngffImage.scale, ngffImage.translation);
84
+ }
85
+
86
+ /**
87
+ * Convert an affine matrix to a flat array for NIfTI header.
88
+ * NIfTI uses srow_x, srow_y, srow_z which are the first 3 rows of the affine.
89
+ *
90
+ * @param affine - The 4x4 affine matrix
91
+ * @returns Object with srow_x, srow_y, srow_z arrays
92
+ */
93
+ export function affineToNiftiSrows(affine: mat4): {
94
+ srow_x: [number, number, number, number];
95
+ srow_y: [number, number, number, number];
96
+ srow_z: [number, number, number, number];
97
+ } {
98
+ // gl-matrix uses column-major order
99
+ // Row 0 (srow_x): elements 0, 4, 8, 12
100
+ // Row 1 (srow_y): elements 1, 5, 9, 13
101
+ // Row 2 (srow_z): elements 2, 6, 10, 14
102
+ return {
103
+ srow_x: [affine[0], affine[4], affine[8], affine[12]],
104
+ srow_y: [affine[1], affine[5], affine[9], affine[13]],
105
+ srow_z: [affine[2], affine[6], affine[10], affine[14]],
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Get pixel dimensions (voxel sizes) from an affine matrix.
111
+ *
112
+ * @param affine - The 4x4 affine matrix
113
+ * @returns Pixel dimensions [x, y, z]
114
+ */
115
+ export function getPixelDimensions(affine: mat4): [number, number, number] {
116
+ // The pixel dimensions are the lengths of the column vectors
117
+ const dx = Math.sqrt(affine[0] ** 2 + affine[1] ** 2 + affine[2] ** 2);
118
+ const dy = Math.sqrt(affine[4] ** 2 + affine[5] ** 2 + affine[6] ** 2);
119
+ const dz = Math.sqrt(affine[8] ** 2 + affine[9] ** 2 + affine[10] ** 2);
120
+
121
+ return [dx, dy, dz];
122
+ }
123
+
124
+ /**
125
+ * Update affine matrix for a cropped/subsampled region.
126
+ *
127
+ * When we load a subregion or at a different resolution, we need to update
128
+ * the affine to reflect the new origin and voxel sizes.
129
+ *
130
+ * @param originalAffine - Original affine matrix
131
+ * @param regionStart - Start pixel indices [z, y, x] of the region
132
+ * @param scaleFactor - Scale factor applied (> 1 means downsampled)
133
+ * @returns Updated affine matrix
134
+ */
135
+ export function updateAffineForRegion(
136
+ originalAffine: mat4,
137
+ regionStart: [number, number, number],
138
+ scaleFactor: [number, number, number],
139
+ ): mat4 {
140
+ const result = mat4.clone(originalAffine);
141
+
142
+ // Scale the voxel dimensions
143
+ // Column 0 (x direction)
144
+ result[0] *= scaleFactor[2]; // x scale factor
145
+ result[1] *= scaleFactor[2];
146
+ result[2] *= scaleFactor[2];
147
+
148
+ // Column 1 (y direction)
149
+ result[4] *= scaleFactor[1]; // y scale factor
150
+ result[5] *= scaleFactor[1];
151
+ result[6] *= scaleFactor[1];
152
+
153
+ // Column 2 (z direction)
154
+ result[8] *= scaleFactor[0]; // z scale factor
155
+ result[9] *= scaleFactor[0];
156
+ result[10] *= scaleFactor[0];
157
+
158
+ // Update translation for region offset
159
+ // New origin = original_origin + regionStart * original_voxel_size
160
+ const originalPixelDims = getPixelDimensions(originalAffine);
161
+
162
+ result[12] += regionStart[2] * originalPixelDims[0]; // x offset
163
+ result[13] += regionStart[1] * originalPixelDims[1]; // y offset
164
+ result[14] += regionStart[0] * originalPixelDims[2]; // z offset
165
+
166
+ return result;
167
+ }
168
+
169
+ /**
170
+ * Calculate the world-space bounding box from an affine and dimensions.
171
+ *
172
+ * @param affine - The 4x4 affine matrix
173
+ * @param dimensions - Volume dimensions [z, y, x]
174
+ * @returns Bounding box { min: [x,y,z], max: [x,y,z] }
175
+ */
176
+ export function calculateWorldBounds(
177
+ affine: mat4,
178
+ dimensions: [number, number, number],
179
+ ): { min: [number, number, number]; max: [number, number, number] } {
180
+ // Calculate all 8 corners of the volume in world space
181
+ const [dimZ, dimY, dimX] = dimensions;
182
+ const corners = [
183
+ [0, 0, 0],
184
+ [dimX, 0, 0],
185
+ [0, dimY, 0],
186
+ [0, 0, dimZ],
187
+ [dimX, dimY, 0],
188
+ [dimX, 0, dimZ],
189
+ [0, dimY, dimZ],
190
+ [dimX, dimY, dimZ],
191
+ ];
192
+
193
+ let minX = Infinity,
194
+ minY = Infinity,
195
+ minZ = Infinity;
196
+ let maxX = -Infinity,
197
+ maxY = -Infinity,
198
+ maxZ = -Infinity;
199
+
200
+ for (const [i, j, k] of corners) {
201
+ // Apply affine: world = affine * [i, j, k, 1]^T
202
+ const wx = affine[0] * i + affine[4] * j + affine[8] * k + affine[12];
203
+ const wy = affine[1] * i + affine[5] * j + affine[9] * k + affine[13];
204
+ const wz = affine[2] * i + affine[6] * j + affine[10] * k + affine[14];
205
+
206
+ minX = Math.min(minX, wx);
207
+ minY = Math.min(minY, wy);
208
+ minZ = Math.min(minZ, wz);
209
+ maxX = Math.max(maxX, wx);
210
+ maxY = Math.max(maxY, wy);
211
+ maxZ = Math.max(maxZ, wz);
212
+ }
213
+
214
+ return {
215
+ min: [minX, minY, minZ],
216
+ max: [maxX, maxY, maxZ],
217
+ };
218
+ }