@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,640 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { Multiscales, NgffImage } from "@fideus-labs/ngff-zarr";
5
+ import type {
6
+ ChunkAlignedRegion,
7
+ ClipPlane,
8
+ ClipPlanes,
9
+ PixelRegion,
10
+ VolumeBounds,
11
+ } from "./types.js";
12
+ import { pixelToWorld, worldToPixel } from "./utils/coordinates.js";
13
+ import { getChunkShape, getVolumeShape } from "./ResolutionSelector.js";
14
+
15
+ /** Maximum number of clip planes supported by NiiVue */
16
+ export const MAX_CLIP_PLANES = 6;
17
+
18
+ /**
19
+ * Normalize a 3D vector to unit length.
20
+ *
21
+ * @param v - Vector to normalize [x, y, z]
22
+ * @returns Normalized vector [x, y, z]
23
+ * @throws Error if vector has zero length
24
+ */
25
+ export function normalizeVector(
26
+ v: [number, number, number],
27
+ ): [number, number, number] {
28
+ const length = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]);
29
+ if (length === 0) {
30
+ throw new Error("Cannot normalize zero-length vector");
31
+ }
32
+ return [v[0] / length, v[1] / length, v[2] / length];
33
+ }
34
+
35
+ /**
36
+ * Create a clip plane from a point and normal vector.
37
+ * The normal is automatically normalized to unit length.
38
+ *
39
+ * @param point - A point on the plane [x, y, z] in world coordinates
40
+ * @param normal - Normal vector pointing toward visible region [x, y, z]
41
+ * @returns ClipPlane with normalized normal
42
+ */
43
+ export function createClipPlane(
44
+ point: [number, number, number],
45
+ normal: [number, number, number],
46
+ ): ClipPlane {
47
+ return {
48
+ point: [...point],
49
+ normal: normalizeVector(normal),
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Create default clip planes (empty array = full volume visible).
55
+ *
56
+ * @param _multiscales - The OME-Zarr multiscales data (unused, kept for API consistency)
57
+ * @returns Empty ClipPlanes array
58
+ */
59
+ export function createDefaultClipPlanes(_multiscales: Multiscales): ClipPlanes {
60
+ return [];
61
+ }
62
+
63
+ /**
64
+ * Get volume bounds from multiscales metadata.
65
+ *
66
+ * @param multiscales - The OME-Zarr multiscales data
67
+ * @returns Volume bounds in world space
68
+ */
69
+ export function getVolumeBoundsFromMultiscales(
70
+ multiscales: Multiscales,
71
+ ): VolumeBounds {
72
+ // Use highest resolution for most accurate bounds
73
+ const image = multiscales.images[0];
74
+ const shape = getVolumeShape(image);
75
+
76
+ const minPixel: [number, number, number] = [0, 0, 0];
77
+ const maxPixel: [number, number, number] = [shape[0], shape[1], shape[2]];
78
+
79
+ const minWorld = pixelToWorld(minPixel, image);
80
+ const maxWorld = pixelToWorld(maxPixel, image);
81
+
82
+ return {
83
+ min: [
84
+ Math.min(minWorld[0], maxWorld[0]),
85
+ Math.min(minWorld[1], maxWorld[1]),
86
+ Math.min(minWorld[2], maxWorld[2]),
87
+ ],
88
+ max: [
89
+ Math.max(minWorld[0], maxWorld[0]),
90
+ Math.max(minWorld[1], maxWorld[1]),
91
+ Math.max(minWorld[2], maxWorld[2]),
92
+ ],
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Convert a normal vector to azimuth and elevation angles (for NiiVue).
98
+ *
99
+ * NiiVue convention:
100
+ * - Azimuth: 0 = posterior (+Y), 90 = right (+X), 180 = anterior (-Y), 270 = left (-X)
101
+ * - Elevation: 0 = horizontal, 90 = superior (+Z), -90 = inferior (-Z)
102
+ *
103
+ * @param normal - Unit normal vector [x, y, z]
104
+ * @returns Object with azimuth and elevation in degrees
105
+ */
106
+ export function normalToAzimuthElevation(
107
+ normal: [number, number, number],
108
+ ): { azimuth: number; elevation: number } {
109
+ const [x, y, z] = normal;
110
+
111
+ // Elevation: angle from XY plane (arcsin of z component)
112
+ // Clamp to [-1, 1] to handle floating point errors
113
+ const elevation = Math.asin(Math.max(-1, Math.min(1, z))) * (180 / Math.PI);
114
+
115
+ // Azimuth: angle in XY plane from +Y axis
116
+ // atan2(x, y) gives angle from +Y, which matches NiiVue's azimuth=0 = posterior (+Y)
117
+ let azimuth = Math.atan2(x, y) * (180 / Math.PI);
118
+ // Normalize to [0, 360)
119
+ azimuth = ((azimuth % 360) + 360) % 360;
120
+
121
+ return { azimuth, elevation };
122
+ }
123
+
124
+ /**
125
+ * Convert azimuth and elevation angles to a unit normal vector.
126
+ *
127
+ * @param azimuth - Azimuth angle in degrees (0 = +Y, 90 = +X)
128
+ * @param elevation - Elevation angle in degrees (0 = horizontal, 90 = +Z)
129
+ * @returns Unit normal vector [x, y, z]
130
+ */
131
+ export function azimuthElevationToNormal(
132
+ azimuth: number,
133
+ elevation: number,
134
+ ): [number, number, number] {
135
+ const azRad = (azimuth * Math.PI) / 180;
136
+ const elRad = (elevation * Math.PI) / 180;
137
+
138
+ const cosEl = Math.cos(elRad);
139
+ const x = cosEl * Math.sin(azRad);
140
+ const y = cosEl * Math.cos(azRad);
141
+ const z = Math.sin(elRad);
142
+
143
+ return [x, y, z];
144
+ }
145
+
146
+ /**
147
+ * Calculate the NiiVue depth parameter for a clip plane.
148
+ *
149
+ * NiiVue's clip plane depth is in normalized texture coordinates where
150
+ * the volume center is at 0.5. Depth represents the signed distance from
151
+ * the center (0) to the plane, where -0.5 is at min boundary and +0.5 is
152
+ * at max boundary. Values beyond [-0.5, 0.5] place the plane outside the volume.
153
+ *
154
+ * @param plane - The clip plane
155
+ * @param volumeBounds - Volume bounds in world space
156
+ * @returns Depth value for NiiVue (typically in range [-0.5, 0.5] for planes within volume)
157
+ */
158
+ export function calculateNiivueDepth(
159
+ plane: ClipPlane,
160
+ volumeBounds: VolumeBounds,
161
+ ): number {
162
+ const { min, max } = volumeBounds;
163
+
164
+ // Volume center
165
+ const center: [number, number, number] = [
166
+ (min[0] + max[0]) / 2,
167
+ (min[1] + max[1]) / 2,
168
+ (min[2] + max[2]) / 2,
169
+ ];
170
+
171
+ // Volume extent
172
+ const extent: [number, number, number] = [
173
+ max[0] - min[0],
174
+ max[1] - min[1],
175
+ max[2] - min[2],
176
+ ];
177
+
178
+ // Signed distance from center to plane along normal
179
+ const { point, normal } = plane;
180
+ const signedDistance = normal[0] * (point[0] - center[0]) +
181
+ normal[1] * (point[1] - center[1]) +
182
+ normal[2] * (point[2] - center[2]);
183
+
184
+ // Full extent along normal direction (using absolute value of each component)
185
+ // This is the "width" of the bounding box when projected onto the normal direction
186
+ const extentAlongNormal = Math.abs(normal[0]) * extent[0] +
187
+ Math.abs(normal[1]) * extent[1] +
188
+ Math.abs(normal[2]) * extent[2];
189
+
190
+ // Avoid division by zero
191
+ if (extentAlongNormal === 0) {
192
+ return 0;
193
+ }
194
+
195
+ // Normalize to NiiVue's coordinate system where volume spans -0.5 to 0.5 from center
196
+ return signedDistance / extentAlongNormal;
197
+ }
198
+
199
+ /**
200
+ * Convert a single clip plane to NiiVue format [depth, azimuth, elevation].
201
+ *
202
+ * NiiVue's shader convention:
203
+ * - The "back" side of the plane (sampleSide > 0) is VISIBLE
204
+ * - The "front" side of the plane (sampleSide < 0) is CLIPPED
205
+ * - sampleSide = dot(shaderNormal, p - 0.5) + depth
206
+ * - NiiVue internally adds 180° to azimuth, which flips the normal direction
207
+ *
208
+ * Our convention:
209
+ * - Normal points toward the VISIBLE region
210
+ *
211
+ * To reconcile these conventions:
212
+ * 1. We negate the normal before computing azimuth/elevation
213
+ * 2. After NiiVue's +180° flip, the shader sees our original normal direction
214
+ * 3. We also negate the depth to match the flipped normal
215
+ *
216
+ * @param plane - The clip plane
217
+ * @param volumeBounds - Volume bounds in world space
218
+ * @returns [depth, azimuth, elevation] for NiiVue
219
+ */
220
+ export function clipPlaneToNiivue(
221
+ plane: ClipPlane,
222
+ volumeBounds: VolumeBounds,
223
+ ): [number, number, number] {
224
+ const depth = calculateNiivueDepth(plane, volumeBounds);
225
+
226
+ // Negate the normal for azimuth/elevation calculation.
227
+ // After NiiVue adds 180° to azimuth, the shader will see our original normal.
228
+ const negatedNormal: [number, number, number] = [
229
+ -plane.normal[0],
230
+ -plane.normal[1],
231
+ -plane.normal[2],
232
+ ];
233
+ const { azimuth, elevation } = normalToAzimuthElevation(negatedNormal);
234
+
235
+ // Also negate the depth to be consistent with the flipped normal.
236
+ // The plane equation dot(n, p-center) + d = 0 changes sign when n is negated.
237
+ const negatedDepth = -depth;
238
+
239
+ return [negatedDepth, azimuth, elevation];
240
+ }
241
+
242
+ /**
243
+ * Convert clip planes to NiiVue format.
244
+ *
245
+ * @param clipPlanes - Array of clip planes
246
+ * @param volumeBounds - Volume bounds in world space
247
+ * @returns Array of [depth, azimuth, elevation] for NiiVue
248
+ */
249
+ export function clipPlanesToNiivue(
250
+ clipPlanes: ClipPlanes,
251
+ volumeBounds: VolumeBounds,
252
+ ): number[][] {
253
+ return clipPlanes.map((plane) => clipPlaneToNiivue(plane, volumeBounds));
254
+ }
255
+
256
+ /**
257
+ * Calculate the signed distance from a point to a plane.
258
+ *
259
+ * Positive = point is on the visible side (same side as normal)
260
+ * Negative = point is on the clipped side (opposite side from normal)
261
+ *
262
+ * @param testPoint - Point to test [x, y, z]
263
+ * @param plane - The clip plane
264
+ * @returns Signed distance
265
+ */
266
+ export function pointToPlaneDistance(
267
+ testPoint: [number, number, number],
268
+ plane: ClipPlane,
269
+ ): number {
270
+ const { point, normal } = plane;
271
+ return (
272
+ normal[0] * (testPoint[0] - point[0]) +
273
+ normal[1] * (testPoint[1] - point[1]) +
274
+ normal[2] * (testPoint[2] - point[2])
275
+ );
276
+ }
277
+
278
+ /**
279
+ * Check if a point is inside all clip planes (on the visible side).
280
+ *
281
+ * @param worldCoord - World coordinate [x, y, z]
282
+ * @param clipPlanes - Array of clip planes
283
+ * @returns True if the point is inside all clip planes (or if there are no planes)
284
+ */
285
+ export function isInsideClipPlanes(
286
+ worldCoord: [number, number, number],
287
+ clipPlanes: ClipPlanes,
288
+ ): boolean {
289
+ for (const plane of clipPlanes) {
290
+ if (pointToPlaneDistance(worldCoord, plane) < 0) {
291
+ return false;
292
+ }
293
+ }
294
+ return true;
295
+ }
296
+
297
+ /**
298
+ * Calculate the axis-aligned bounding box that contains the clipped region.
299
+ *
300
+ * For oblique clip planes, this finds the intersection of the clip planes
301
+ * with the volume bounds and returns the AABB of that intersection.
302
+ *
303
+ * This is used for data fetching (zarr is always axis-aligned).
304
+ *
305
+ * @param clipPlanes - Array of clip planes
306
+ * @param volumeBounds - Full volume bounds in world space
307
+ * @returns Bounding box of the clipped region
308
+ */
309
+ export function clipPlanesToBoundingBox(
310
+ clipPlanes: ClipPlanes,
311
+ volumeBounds: VolumeBounds,
312
+ ): VolumeBounds {
313
+ // If no clip planes, return full volume
314
+ if (clipPlanes.length === 0) {
315
+ return {
316
+ min: [...volumeBounds.min],
317
+ max: [...volumeBounds.max],
318
+ };
319
+ }
320
+
321
+ // Start with full volume bounds
322
+ let minX = volumeBounds.min[0];
323
+ let maxX = volumeBounds.max[0];
324
+ let minY = volumeBounds.min[1];
325
+ let maxY = volumeBounds.max[1];
326
+ let minZ = volumeBounds.min[2];
327
+ let maxZ = volumeBounds.max[2];
328
+
329
+ // For each clip plane, constrain the bounding box
330
+ // This is an approximation: we check the 8 corners and constrain based on
331
+ // which corners are clipped. For axis-aligned planes, this is exact.
332
+ // For oblique planes, it's a conservative approximation.
333
+ for (const plane of clipPlanes) {
334
+ const { point, normal } = plane;
335
+
336
+ // For axis-aligned normals, we can compute exact bounds
337
+ const absNx = Math.abs(normal[0]);
338
+ const absNy = Math.abs(normal[1]);
339
+ const absNz = Math.abs(normal[2]);
340
+
341
+ // Check if plane is approximately axis-aligned
342
+ const tolerance = 0.001;
343
+
344
+ if (absNx > 1 - tolerance && absNy < tolerance && absNz < tolerance) {
345
+ // X-aligned plane
346
+ if (normal[0] > 0) {
347
+ // Normal points +X, clips -X side
348
+ minX = Math.max(minX, point[0]);
349
+ } else {
350
+ // Normal points -X, clips +X side
351
+ maxX = Math.min(maxX, point[0]);
352
+ }
353
+ } else if (
354
+ absNy > 1 - tolerance && absNx < tolerance && absNz < tolerance
355
+ ) {
356
+ // Y-aligned plane
357
+ if (normal[1] > 0) {
358
+ minY = Math.max(minY, point[1]);
359
+ } else {
360
+ maxY = Math.min(maxY, point[1]);
361
+ }
362
+ } else if (
363
+ absNz > 1 - tolerance && absNx < tolerance && absNy < tolerance
364
+ ) {
365
+ // Z-aligned plane
366
+ if (normal[2] > 0) {
367
+ minZ = Math.max(minZ, point[2]);
368
+ } else {
369
+ maxZ = Math.min(maxZ, point[2]);
370
+ }
371
+ } else {
372
+ // Oblique plane - use conservative approximation
373
+ // Find the extent of the plane intersection with the volume
374
+ // For simplicity, we use the point on the plane as a bound hint
375
+ // This is conservative (may fetch more data than needed)
376
+
377
+ // Project the plane point onto each axis and use as potential bound
378
+ // Only constrain if the plane actually intersects that face
379
+ if (normal[0] > tolerance) {
380
+ minX = Math.max(minX, Math.min(point[0], maxX));
381
+ } else if (normal[0] < -tolerance) {
382
+ maxX = Math.min(maxX, Math.max(point[0], minX));
383
+ }
384
+
385
+ if (normal[1] > tolerance) {
386
+ minY = Math.max(minY, Math.min(point[1], maxY));
387
+ } else if (normal[1] < -tolerance) {
388
+ maxY = Math.min(maxY, Math.max(point[1], minY));
389
+ }
390
+
391
+ if (normal[2] > tolerance) {
392
+ minZ = Math.max(minZ, Math.min(point[2], maxZ));
393
+ } else if (normal[2] < -tolerance) {
394
+ maxZ = Math.min(maxZ, Math.max(point[2], minZ));
395
+ }
396
+ }
397
+ }
398
+
399
+ // Ensure valid bounds (min <= max)
400
+ return {
401
+ min: [
402
+ Math.min(minX, maxX),
403
+ Math.min(minY, maxY),
404
+ Math.min(minZ, maxZ),
405
+ ],
406
+ max: [
407
+ Math.max(minX, maxX),
408
+ Math.max(minY, maxY),
409
+ Math.max(minZ, maxZ),
410
+ ],
411
+ };
412
+ }
413
+
414
+ /**
415
+ * Convert clip planes to a pixel region for a specific NgffImage.
416
+ *
417
+ * This calculates the axis-aligned bounding box of the clipped region
418
+ * and converts it to pixel coordinates. When viewportBounds is provided,
419
+ * the result is further constrained to only include the visible viewport
420
+ * area (for viewport-aware resolution selection).
421
+ *
422
+ * @param clipPlanes - Array of clip planes
423
+ * @param volumeBounds - Full volume bounds in world space
424
+ * @param ngffImage - The NgffImage to convert to
425
+ * @param viewportBounds - Optional viewport bounds to intersect with
426
+ * @returns Pixel region [z, y, x] start and end indices
427
+ */
428
+ export function clipPlanesToPixelRegion(
429
+ clipPlanes: ClipPlanes,
430
+ volumeBounds: VolumeBounds,
431
+ ngffImage: NgffImage,
432
+ viewportBounds?: VolumeBounds,
433
+ ): PixelRegion {
434
+ let bounds = clipPlanesToBoundingBox(clipPlanes, volumeBounds);
435
+
436
+ // If viewport bounds are provided, intersect with them
437
+ if (viewportBounds) {
438
+ bounds = {
439
+ min: [
440
+ Math.max(bounds.min[0], viewportBounds.min[0]),
441
+ Math.max(bounds.min[1], viewportBounds.min[1]),
442
+ Math.max(bounds.min[2], viewportBounds.min[2]),
443
+ ],
444
+ max: [
445
+ Math.min(bounds.max[0], viewportBounds.max[0]),
446
+ Math.min(bounds.max[1], viewportBounds.max[1]),
447
+ Math.min(bounds.max[2], viewportBounds.max[2]),
448
+ ],
449
+ };
450
+ // Ensure valid bounds
451
+ for (let i = 0; i < 3; i++) {
452
+ if (bounds.min[i] > bounds.max[i]) {
453
+ bounds.min[i] = bounds.max[i] = (bounds.min[i] + bounds.max[i]) * 0.5;
454
+ }
455
+ }
456
+ }
457
+
458
+ const shape = getVolumeShape(ngffImage);
459
+
460
+ // Convert world corners to pixel coordinates
461
+ const minWorld: [number, number, number] = [
462
+ bounds.min[0],
463
+ bounds.min[1],
464
+ bounds.min[2],
465
+ ];
466
+ const maxWorld: [number, number, number] = [
467
+ bounds.max[0],
468
+ bounds.max[1],
469
+ bounds.max[2],
470
+ ];
471
+
472
+ const minPixel = worldToPixel(minWorld, ngffImage);
473
+ const maxPixel = worldToPixel(maxWorld, ngffImage);
474
+
475
+ // Ensure proper ordering and clamp to valid range
476
+ const start: [number, number, number] = [
477
+ Math.max(0, Math.floor(Math.min(minPixel[0], maxPixel[0]))),
478
+ Math.max(0, Math.floor(Math.min(minPixel[1], maxPixel[1]))),
479
+ Math.max(0, Math.floor(Math.min(minPixel[2], maxPixel[2]))),
480
+ ];
481
+
482
+ const end: [number, number, number] = [
483
+ Math.min(shape[0], Math.ceil(Math.max(minPixel[0], maxPixel[0]))),
484
+ Math.min(shape[1], Math.ceil(Math.max(minPixel[1], maxPixel[1]))),
485
+ Math.min(shape[2], Math.ceil(Math.max(minPixel[2], maxPixel[2]))),
486
+ ];
487
+
488
+ return { start, end };
489
+ }
490
+
491
+ /**
492
+ * Align a pixel region to chunk boundaries.
493
+ *
494
+ * This expands the region to include complete chunks, which is necessary
495
+ * for efficient zarr fetching.
496
+ *
497
+ * @param region - The pixel region to align
498
+ * @param ngffImage - The NgffImage (for chunk shape)
499
+ * @returns Chunk-aligned region with clipping information
500
+ */
501
+ export function alignToChunks(
502
+ region: PixelRegion,
503
+ ngffImage: NgffImage,
504
+ ): ChunkAlignedRegion {
505
+ const chunkShape = getChunkShape(ngffImage);
506
+ const volumeShape = getVolumeShape(ngffImage);
507
+
508
+ // Align start down to chunk boundary
509
+ const chunkAlignedStart: [number, number, number] = [
510
+ Math.floor(region.start[0] / chunkShape[0]) * chunkShape[0],
511
+ Math.floor(region.start[1] / chunkShape[1]) * chunkShape[1],
512
+ Math.floor(region.start[2] / chunkShape[2]) * chunkShape[2],
513
+ ];
514
+
515
+ // Align end up to chunk boundary (but don't exceed volume size)
516
+ const chunkAlignedEnd: [number, number, number] = [
517
+ Math.min(
518
+ Math.ceil(region.end[0] / chunkShape[0]) * chunkShape[0],
519
+ volumeShape[0],
520
+ ),
521
+ Math.min(
522
+ Math.ceil(region.end[1] / chunkShape[1]) * chunkShape[1],
523
+ volumeShape[1],
524
+ ),
525
+ Math.min(
526
+ Math.ceil(region.end[2] / chunkShape[2]) * chunkShape[2],
527
+ volumeShape[2],
528
+ ),
529
+ ];
530
+
531
+ // Check if alignment changed the region
532
+ const needsClipping = chunkAlignedStart[0] !== region.start[0] ||
533
+ chunkAlignedStart[1] !== region.start[1] ||
534
+ chunkAlignedStart[2] !== region.start[2] ||
535
+ chunkAlignedEnd[0] !== region.end[0] ||
536
+ chunkAlignedEnd[1] !== region.end[1] ||
537
+ chunkAlignedEnd[2] !== region.end[2];
538
+
539
+ return {
540
+ start: region.start,
541
+ end: region.end,
542
+ chunkAlignedStart,
543
+ chunkAlignedEnd,
544
+ needsClipping,
545
+ };
546
+ }
547
+
548
+ /**
549
+ * Create an axis-aligned clip plane at a specific position.
550
+ *
551
+ * The normal points toward the VISIBLE region (the region to keep).
552
+ * NiiVue's shader convention is that the "back" side of the plane (where
553
+ * dot(n, p-center) + depth > 0) is visible.
554
+ *
555
+ * @param axis - The axis ('x', 'y', or 'z')
556
+ * @param position - Position along the axis in world coordinates
557
+ * @param direction - Which side to keep visible ('positive' or 'negative')
558
+ * @param volumeBounds - Volume bounds for centering the point
559
+ * @returns ClipPlane with point at center of volume projected to the plane
560
+ */
561
+ export function createAxisAlignedClipPlane(
562
+ axis: "x" | "y" | "z",
563
+ position: number,
564
+ direction: "positive" | "negative",
565
+ volumeBounds: VolumeBounds,
566
+ ): ClipPlane {
567
+ const { min, max } = volumeBounds;
568
+ const center: [number, number, number] = [
569
+ (min[0] + max[0]) / 2,
570
+ (min[1] + max[1]) / 2,
571
+ (min[2] + max[2]) / 2,
572
+ ];
573
+
574
+ let point: [number, number, number];
575
+ let normal: [number, number, number];
576
+
577
+ // Normal points toward the visible region
578
+ const sign = direction === "positive" ? 1 : -1;
579
+
580
+ switch (axis) {
581
+ case "x":
582
+ point = [position, center[1], center[2]];
583
+ normal = [sign, 0, 0];
584
+ break;
585
+ case "y":
586
+ point = [center[0], position, center[2]];
587
+ normal = [0, sign, 0];
588
+ break;
589
+ case "z":
590
+ point = [center[0], center[1], position];
591
+ normal = [0, 0, sign];
592
+ break;
593
+ }
594
+
595
+ return { point, normal };
596
+ }
597
+
598
+ /**
599
+ * Validate clip planes array.
600
+ *
601
+ * @param clipPlanes - Array of clip planes to validate
602
+ * @throws Error if validation fails
603
+ */
604
+ export function validateClipPlanes(clipPlanes: ClipPlanes): void {
605
+ if (clipPlanes.length > MAX_CLIP_PLANES) {
606
+ throw new Error(
607
+ `Too many clip planes: ${clipPlanes.length} exceeds maximum of ${MAX_CLIP_PLANES}`,
608
+ );
609
+ }
610
+
611
+ for (let i = 0; i < clipPlanes.length; i++) {
612
+ const plane = clipPlanes[i];
613
+
614
+ // Check point is valid
615
+ if (
616
+ !Array.isArray(plane.point) ||
617
+ plane.point.length !== 3 ||
618
+ plane.point.some((v) => typeof v !== "number" || !isFinite(v))
619
+ ) {
620
+ throw new Error(`Invalid point in clip plane ${i}`);
621
+ }
622
+
623
+ // Check normal is valid
624
+ if (
625
+ !Array.isArray(plane.normal) ||
626
+ plane.normal.length !== 3 ||
627
+ plane.normal.some((v) => typeof v !== "number" || !isFinite(v))
628
+ ) {
629
+ throw new Error(`Invalid normal in clip plane ${i}`);
630
+ }
631
+
632
+ // Check normal is not zero
633
+ const length = Math.sqrt(
634
+ plane.normal[0] ** 2 + plane.normal[1] ** 2 + plane.normal[2] ** 2,
635
+ );
636
+ if (length < 0.0001) {
637
+ throw new Error(`Zero-length normal in clip plane ${i}`);
638
+ }
639
+ }
640
+ }