@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,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
|
+
}
|