@itk-viewer/remote-viewport
Advanced tools
Comparing version 0.2.7 to 0.2.8
# @itk-viewer/remote-viewport | ||
## 0.2.8 | ||
### Patch Changes | ||
- c13abd2: Add view-2d-vtkjs. Fix computeRanges. | ||
- 991d042: Performance improvements to canvas blitting and render loop | ||
- 14817ae: Load image with bounded by clip bounds. | ||
- Updated dependencies [c13abd2] | ||
- Updated dependencies [991d042] | ||
- Updated dependencies [14817ae] | ||
- @itk-viewer/viewer@0.2.5 | ||
- @itk-viewer/io@0.1.5 | ||
## 0.2.7 | ||
@@ -4,0 +17,0 @@ |
@@ -8,3 +8,3 @@ /// <reference types="gl-matrix/index.js" /> | ||
import { Bounds, ReadOnlyDimensionBounds } from '@itk-viewer/io/types.js'; | ||
type RendererProps = { | ||
type RendererState = { | ||
density: number; | ||
@@ -20,3 +20,3 @@ cameraPose: ReadonlyMat4; | ||
}[keyof T][]; | ||
export type RendererEntries = Entries<RendererProps>; | ||
export type RendererEntries = Entries<RendererState>; | ||
export type Context = { | ||
@@ -26,11 +26,14 @@ serverConfig?: unknown; | ||
frame?: Image; | ||
rendererProps: RendererProps; | ||
queuedRendererEvents: RendererEntries; | ||
stagedRendererEvents: RendererEntries; | ||
rendererState: RendererState; | ||
queuedRendererCommands: RendererEntries; | ||
stagedRendererCommands: RendererEntries; | ||
viewport: ActorRefFrom<typeof viewportMachine>; | ||
maxImageBytes: number; | ||
imageScale: number; | ||
toRendererCoordinateSystem: ReadonlyMat4; | ||
imageWorldBounds: Bounds; | ||
imageIndexClipBounds?: ReadOnlyDimensionBounds; | ||
loadedImageIndexBounds?: ReadOnlyDimensionBounds; | ||
clipBounds: Bounds; | ||
loadedImageClipBounds: Bounds; | ||
imageWorldToIndex: ReadonlyMat4; | ||
@@ -44,3 +47,3 @@ }; | ||
type: 'updateRenderer'; | ||
props: Partial<RendererProps>; | ||
state: Partial<RendererState>; | ||
}; | ||
@@ -50,4 +53,4 @@ type RenderEvent = { | ||
}; | ||
type SetImage = { | ||
type: 'setImage'; | ||
type ImageAssigned = { | ||
type: 'imageAssigned'; | ||
image: MultiscaleSpatialImage; | ||
@@ -74,7 +77,16 @@ }; | ||
}; | ||
type UpdateImageScaleResult = { | ||
type: 'noChange'; | ||
} | { | ||
type: 'coarserScale'; | ||
imageScale: number; | ||
} | { | ||
type: 'load'; | ||
imageScale: number; | ||
}; | ||
type UpdateImageScaleDone = { | ||
type: 'xstate.done.actor.updateImageScale'; | ||
output: number; | ||
output: UpdateImageScaleResult; | ||
}; | ||
export declare const remoteMachine: import("xstate").StateMachine<Context, ConnectEvent | UpdateRendererEvent | RenderEvent | SetImage | SlowFps | FastFps | CameraPoseUpdated | ImageProcessorDone | UpdateImageScaleDone | { | ||
export declare const remoteMachine: import("xstate").StateMachine<Context, ConnectEvent | UpdateRendererEvent | RenderEvent | ImageAssigned | SlowFps | FastFps | CameraPoseUpdated | ImageProcessorDone | UpdateImageScaleDone | { | ||
type: 'setResolution'; | ||
@@ -85,6 +97,7 @@ resolution: [number, number]; | ||
clipBounds: Bounds; | ||
imageScale?: number | undefined; | ||
} | { | ||
type: 'setImageScale'; | ||
imageScale: number; | ||
} | { | ||
type: 'sendCommands'; | ||
}, import("xstate").ProvidedActor, import("xstate").ParameterizedObject, import("xstate").ParameterizedObject, string, string, { | ||
@@ -169,3 +182,3 @@ viewport: import("xstate").Actor<import("xstate").StateMachine<{ | ||
}, import("xstate").ProvidedActor, import("xstate").ParameterizedObject, import("xstate").ParameterizedObject, string, string>>>; | ||
}, import("xstate").NonReducibleUnknown, import("xstate").ResolveTypegenMeta<import("xstate").TypegenDisabled, ConnectEvent | UpdateRendererEvent | RenderEvent | SetImage | SlowFps | FastFps | CameraPoseUpdated | ImageProcessorDone | UpdateImageScaleDone | { | ||
}, import("xstate").NonReducibleUnknown, import("xstate").ResolveTypegenMeta<import("xstate").TypegenDisabled, ConnectEvent | UpdateRendererEvent | RenderEvent | ImageAssigned | SlowFps | FastFps | CameraPoseUpdated | ImageProcessorDone | UpdateImageScaleDone | { | ||
type: 'setResolution'; | ||
@@ -176,7 +189,8 @@ resolution: [number, number]; | ||
clipBounds: Bounds; | ||
imageScale?: number | undefined; | ||
} | { | ||
type: 'setImageScale'; | ||
imageScale: number; | ||
} | { | ||
type: 'sendCommands'; | ||
}, import("xstate").ProvidedActor, import("xstate").ParameterizedObject, import("xstate").ParameterizedObject, string, string>>; | ||
export {}; |
@@ -17,5 +17,5 @@ import { mat4 } from 'gl-matrix'; | ||
const image = getImage(context); | ||
if (context.rendererProps.imageScale === undefined) | ||
if (context.rendererState.imageScale === undefined) | ||
throw new Error('imageScale not found'); | ||
const currentScale = context.rendererProps.imageScale; | ||
const currentScale = context.rendererState.imageScale; | ||
const scaleChange = event.type === 'slowFps' ? 1 : -1; | ||
@@ -25,6 +25,21 @@ const targetScale = currentScale + scaleChange; | ||
}; | ||
const computeBytes = async (image, targetScale) => { | ||
const voxelCount = await getVoxelCount(image, targetScale); | ||
const computeBytes = async (image, targetScale, clipBounds) => { | ||
const voxelCount = await getVoxelCount(image, targetScale, clipBounds); | ||
return getBytes(image, voxelCount); | ||
}; | ||
const EPSILON = 0.000001; | ||
const checkBoundsBigger = (fullImage, benchmark, sample) => { | ||
// clamp rendered bounds to max size of image | ||
sample.forEach((b, i) => { | ||
sample[i] = | ||
i % 2 | ||
? Math.min(b, fullImage[i]) // high bound case | ||
: Math.max(b, fullImage[i]); // low bound case | ||
}); | ||
return benchmark.some((loaded, i) => { | ||
return i % 2 | ||
? sample[i] - loaded > EPSILON // high bound case: currentBounds[i] > loadedBound | ||
: loaded - sample[i] > EPSILON; // low bound case: currentBounds[i] < loadedBound | ||
}); | ||
}; | ||
export const remoteMachine = createMachine({ | ||
@@ -34,3 +49,3 @@ types: {}, | ||
context: ({ input }) => ({ | ||
rendererProps: { | ||
rendererState: { | ||
density: 30, | ||
@@ -41,4 +56,5 @@ cameraPose: mat4.create(), | ||
}, | ||
queuedRendererEvents: [], | ||
stagedRendererEvents: [], | ||
queuedRendererCommands: [], | ||
stagedRendererCommands: [], | ||
imageScale: 0, | ||
// computed async image values | ||
@@ -48,2 +64,3 @@ toRendererCoordinateSystem: mat4.create(), | ||
clipBounds: createBounds(), | ||
loadedImageClipBounds: createBounds(), | ||
imageWorldToIndex: mat4.create(), | ||
@@ -58,3 +75,3 @@ maxImageBytes: MAX_IMAGE_BYTES_DEFAULT, | ||
on: { | ||
setImage: '.updatingScale', | ||
imageAssigned: '.updatingScale', | ||
setImageScale: '.updatingScale', | ||
@@ -64,2 +81,14 @@ }, | ||
idle: {}, | ||
raiseCoarserScale: { | ||
entry: raise(({ event }) => { | ||
const result = event.output; | ||
if (result.type === 'coarserScale') { | ||
return { | ||
type: 'setImageScale', | ||
imageScale: result.imageScale, | ||
}; | ||
} | ||
throw new Error('Unexpected result type: ' + result.type); | ||
}), | ||
}, | ||
updatingScale: { | ||
@@ -74,3 +103,3 @@ // Ensure imageScale is not the same as before and fits in memory | ||
return event.imageScale; | ||
if (event.type === 'setImage') | ||
if (event.type === 'imageAssigned') | ||
return image.coarsestScale; | ||
@@ -87,25 +116,47 @@ throw new Error('Unexpected event type: ' + event.type); | ||
const image = getImage(context); | ||
if (imageScale === context.rendererProps.imageScale) | ||
return; | ||
const imageBytes = await computeBytes(image, imageScale); | ||
if (imageBytes > context.maxImageBytes) | ||
return; | ||
return imageScale; | ||
const boundsBiggerThanLoaded = checkBoundsBigger(context.imageWorldBounds, context.loadedImageClipBounds, context.clipBounds); | ||
if (!boundsBiggerThanLoaded && | ||
imageScale === context.imageScale) | ||
return { type: 'noChange' }; // no new data to load | ||
// Always try to load base scale | ||
if (imageScale !== image.coarsestScale) { | ||
const imageBytes = await computeBytes(image, imageScale, context.clipBounds); | ||
if (imageBytes > context.maxImageBytes) | ||
return { type: 'coarserScale', imageScale: imageScale + 1 }; | ||
} | ||
return { type: 'load', imageScale }; | ||
}), | ||
onDone: { | ||
guard: ({ event }) => event.output !== undefined, | ||
target: 'updatingComputedValues', | ||
}, | ||
onDone: [ | ||
{ | ||
guard: ({ event }) => event.output.type === 'coarserScale', | ||
target: 'raiseCoarserScale', | ||
}, | ||
{ | ||
guard: ({ event }) => event.output.type === 'load', | ||
target: 'updatingComputedValues', | ||
}, | ||
], | ||
}, | ||
}, | ||
updatingComputedValues: { | ||
entry: [ | ||
assign({ | ||
imageScale: ({ event }) => { | ||
const result = event.output; | ||
if (result.type === 'load') | ||
return result.imageScale; | ||
throw new Error('Unexpected result type: ' + result.type); | ||
}, | ||
}), | ||
], | ||
// For new image scale, compute imageWorldBounds, imageWorldToIndex, toRendererCoordinateSystem | ||
invoke: { | ||
src: 'imageProcessor', | ||
input: ({ context, event }) => { | ||
input: ({ context }) => { | ||
const image = getImage(context); | ||
const imageScale = event.output; | ||
const { imageScale, clipBounds } = context; | ||
return { | ||
image, | ||
imageScale, | ||
clipBounds, | ||
}; | ||
@@ -120,2 +171,7 @@ }, | ||
}), | ||
'computeImageIndexClipBounds', | ||
assign({ | ||
loadedImageIndexBounds: ({ context }) => context.imageIndexClipBounds, | ||
loadedImageClipBounds: ({ context }) => context.clipBounds, | ||
}), | ||
], | ||
@@ -129,3 +185,3 @@ target: 'checkingFirstImage', | ||
{ | ||
guard: ({ context }) => context.rendererProps.image === undefined, | ||
guard: ({ context }) => context.rendererState.image === undefined, | ||
target: 'initClipBounds', | ||
@@ -141,3 +197,2 @@ }, | ||
clipBounds: event.output.bounds, | ||
imageScale: event.output.imageScale, | ||
}; | ||
@@ -148,16 +203,20 @@ }), | ||
sendingToRenderer: { | ||
entry: raise(({ event }) => { | ||
const { image, imageScale } = event.output; | ||
return { | ||
type: 'updateRenderer', | ||
props: { | ||
image: image.name, | ||
imageScale, | ||
}, | ||
}; | ||
}), | ||
entry: [ | ||
raise(({ event }) => { | ||
const { image, imageScale } = event | ||
.output; | ||
return { | ||
type: 'updateRenderer', | ||
state: { | ||
image: image.name, | ||
imageScale, | ||
}, | ||
}; | ||
}), | ||
'updateNormalizedClipBounds', | ||
], | ||
}, | ||
}, | ||
}, | ||
// root state captures initial rendererProps events even when disconnected | ||
// root state captures initial updateRenderer events, even when disconnected | ||
root: { | ||
@@ -173,15 +232,15 @@ entry: [ | ||
assign({ | ||
rendererProps: ({ event: { props }, context }) => { | ||
rendererState: ({ event: { state }, context }) => { | ||
return { | ||
...context.rendererProps, | ||
...props, | ||
...context.rendererState, | ||
...state, | ||
}; | ||
}, | ||
queuedRendererEvents: ({ event: { props }, context }) => [ | ||
...context.queuedRendererEvents, | ||
...getEntries(props), | ||
queuedRendererCommands: ({ event: { state }, context }) => [ | ||
...context.queuedRendererCommands, | ||
...getEntries(state), | ||
], | ||
}), | ||
// Trigger a render (if in idle state) | ||
raise({ type: 'render' }), | ||
raise({ type: 'sendCommands' }), | ||
], | ||
@@ -194,3 +253,3 @@ }, | ||
type: 'updateRenderer', | ||
props: { cameraPose: event.pose }, | ||
state: { cameraPose: event.pose }, | ||
}; | ||
@@ -205,3 +264,3 @@ }), | ||
type: 'updateRenderer', | ||
props: { renderSize: event.resolution }, | ||
state: { renderSize: event.resolution }, | ||
}; | ||
@@ -216,21 +275,8 @@ }), | ||
}), | ||
raise(({ context: { viewport, clipBounds, imageWorldToIndex, rendererProps, }, event, }) => { | ||
var _a; | ||
const imageScale = (_a = event.imageScale) !== null && _a !== void 0 ? _a : rendererProps.imageScale; | ||
const { image } = viewport.getSnapshot().context; | ||
if (!image || imageScale === undefined) | ||
throw new Error('image or imageScale not found'); | ||
const fullIndexBounds = image.getIndexBounds(imageScale); | ||
const imageIndexClipBounds = worldBoundsToIndexBounds({ | ||
bounds: clipBounds, | ||
fullIndexBounds, | ||
worldToIndex: imageWorldToIndex, | ||
}); | ||
// Compute normalized bounds in image space | ||
const spatialImageBounds = ensuredDims([0, 1], XYZ, fullIndexBounds); | ||
const ranges = Object.fromEntries(XYZ.map((dim) => [dim, spatialImageBounds.get(dim)[1]])); | ||
const normalizedClipBounds = XYZ.flatMap((dim) => { var _a; return (_a = imageIndexClipBounds.get(dim)) === null || _a === void 0 ? void 0 : _a.map((v) => v / ranges[dim]); }); | ||
'computeImageIndexClipBounds', | ||
'updateNormalizedClipBounds', | ||
raise(({ context }) => { | ||
return { | ||
type: 'updateRenderer', | ||
props: { normalizedClipBounds }, | ||
type: 'setImageScale', | ||
imageScale: context.imageScale, | ||
}; | ||
@@ -268,4 +314,4 @@ }), | ||
server: ({ event }) => event.output, | ||
// initially, send all props to renderer | ||
queuedRendererEvents: ({ context }) => getEntries(context.rendererProps), | ||
// initially, send all commands to renderer | ||
queuedRendererCommands: ({ context }) => getEntries(context.rendererState), | ||
}), | ||
@@ -305,2 +351,41 @@ target: 'online', | ||
}, | ||
commandLoop: { | ||
initial: 'sendingCommands', | ||
states: { | ||
sendingCommands: { | ||
invoke: { | ||
id: 'commandSender', | ||
src: 'commandSender', | ||
input: ({ context }) => ({ | ||
commands: [...context.stagedRendererCommands], | ||
context, | ||
}), | ||
onDone: { | ||
target: 'idle', | ||
}, | ||
onError: { | ||
actions: (e) => console.error(`Error while sending commands.`, e.event.data.stack ?? e.event.data), | ||
target: 'idle', // soldier on | ||
}, | ||
}, | ||
}, | ||
idle: { | ||
always: { | ||
// More commands while sending commands? Then send commands. | ||
guard: ({ context }) => context.queuedRendererCommands.length > 0, | ||
target: 'sendingCommands', | ||
}, | ||
on: { | ||
sendCommands: { target: 'sendingCommands' }, | ||
}, | ||
exit: assign({ | ||
// consumes queue in prep for renderer | ||
stagedRendererCommands: ({ context }) => [ | ||
...context.queuedRendererCommands, | ||
], | ||
queuedRendererCommands: [], | ||
}), | ||
}, | ||
}, | ||
}, | ||
renderLoop: { | ||
@@ -314,3 +399,2 @@ initial: 'render', | ||
input: ({ context }) => ({ | ||
events: [...context.stagedRendererEvents], | ||
context, | ||
@@ -333,6 +417,3 @@ }), | ||
onError: { | ||
actions: (e) => { | ||
var _a; | ||
return console.error(`Error while updating renderer.`, (_a = e.event.data.stack) !== null && _a !== void 0 ? _a : e.event.data); | ||
}, | ||
actions: (e) => console.error(`Error while updating renderer.`, e.event.data.stack ?? e.event.data), | ||
target: 'idle', // soldier on | ||
@@ -343,17 +424,5 @@ }, | ||
idle: { | ||
always: { | ||
// Renderer props changed while rendering? Then render. | ||
guard: ({ context }) => context.queuedRendererEvents.length > 0, | ||
target: 'render', | ||
}, | ||
on: { | ||
render: { target: 'render' }, | ||
}, | ||
exit: assign({ | ||
// consumes queue in prep for renderer | ||
stagedRendererEvents: ({ context }) => [ | ||
...context.queuedRendererEvents, | ||
], | ||
queuedRendererEvents: [], | ||
}), | ||
}, | ||
@@ -367,3 +436,41 @@ }, | ||
}, | ||
}, { | ||
actions: { | ||
computeImageIndexClipBounds: assign({ | ||
imageIndexClipBounds: ({ context: { viewport, imageWorldToIndex, clipBounds, imageScale }, }) => { | ||
const { image } = viewport.getSnapshot().context; | ||
if (!image || imageScale === undefined) | ||
throw new Error('image or imageScale not found'); | ||
const fullIndexBounds = image.getIndexBounds(imageScale); | ||
return worldBoundsToIndexBounds({ | ||
bounds: clipBounds, | ||
fullIndexBounds, | ||
worldToIndex: imageWorldToIndex, | ||
}); | ||
}, | ||
}), | ||
updateNormalizedClipBounds: raise(({ context: { viewport, imageIndexClipBounds, loadedImageIndexBounds, imageScale, }, }) => { | ||
const { image } = viewport.getSnapshot().context; | ||
if (!image || imageScale === undefined) | ||
throw new Error('image or imageScale not found'); | ||
if (!imageIndexClipBounds) | ||
throw new Error('imageIndexClipBounds not found'); | ||
if (!loadedImageIndexBounds) | ||
throw new Error('loadedImageIndexBounds not found'); | ||
// Compute normalized bounds in loaded image space | ||
const spatialImageBounds = ensuredDims([0, 1], XYZ, loadedImageIndexBounds); | ||
const normalizedClipBounds = XYZ.flatMap((dim) => { | ||
const [floor, top] = spatialImageBounds.get(dim); | ||
const range = top - floor; | ||
return imageIndexClipBounds | ||
.get(dim) | ||
.map((v) => (v - floor) / range); | ||
}); | ||
return { | ||
type: 'updateRenderer', | ||
state: { normalizedClipBounds }, | ||
}; | ||
}), | ||
}, | ||
}); | ||
//# sourceMappingURL=remote-machine.js.map |
@@ -6,2 +6,3 @@ /// <reference types="gl-matrix/index.js" /> | ||
import { RendererEntries, remoteMachine, Context } from './remote-machine.js'; | ||
import { Bounds } from '@itk-viewer/io/types.js'; | ||
export type { Image } from '@itk-wasm/htj2k'; | ||
@@ -24,3 +25,3 @@ type RenderedFrame = { | ||
context: MachineContext; | ||
events: RendererEntries; | ||
commands: RendererEntries; | ||
}; | ||
@@ -41,3 +42,3 @@ type ConnectInput = { | ||
type: "updateRenderer"; | ||
props: Partial<{ | ||
state: Partial<{ | ||
density: number; | ||
@@ -48,3 +49,3 @@ cameraPose: ReadonlyMat4; | ||
imageScale?: number | undefined; | ||
normalizedClipBounds: import("@itk-viewer/io/types.js").Bounds; | ||
normalizedClipBounds: Bounds; | ||
}>; | ||
@@ -54,3 +55,3 @@ } | { | ||
} | { | ||
type: "setImage"; | ||
type: "imageAssigned"; | ||
image: import("@itk-viewer/io/MultiscaleSpatialImage.js").MultiscaleSpatialImage; | ||
@@ -67,3 +68,3 @@ } | { | ||
output: { | ||
bounds: import("@itk-viewer/io/types.js").Bounds; | ||
bounds: Bounds; | ||
toRendererCoordinateSystem: ReadonlyMat4; | ||
@@ -75,3 +76,11 @@ imageScale: number; | ||
type: "xstate.done.actor.updateImageScale"; | ||
output: number; | ||
output: { | ||
type: "noChange"; | ||
} | { | ||
type: "coarserScale"; | ||
imageScale: number; | ||
} | { | ||
type: "load"; | ||
imageScale: number; | ||
}; | ||
} | { | ||
@@ -82,7 +91,8 @@ type: "setResolution"; | ||
type: "setClipBounds"; | ||
clipBounds: import("@itk-viewer/io/types.js").Bounds; | ||
imageScale?: number | undefined; | ||
clipBounds: Bounds; | ||
} | { | ||
type: "setImageScale"; | ||
imageScale: number; | ||
} | { | ||
type: "sendCommands"; | ||
}, import("xstate").ProvidedActor, import("xstate").ParameterizedObject, import("xstate").ParameterizedObject, string, string, { | ||
@@ -172,3 +182,3 @@ viewport: import("xstate").Actor<import("xstate").StateMachine<{ | ||
type: "updateRenderer"; | ||
props: Partial<{ | ||
state: Partial<{ | ||
density: number; | ||
@@ -179,3 +189,3 @@ cameraPose: ReadonlyMat4; | ||
imageScale?: number | undefined; | ||
normalizedClipBounds: import("@itk-viewer/io/types.js").Bounds; | ||
normalizedClipBounds: Bounds; | ||
}>; | ||
@@ -185,3 +195,3 @@ } | { | ||
} | { | ||
type: "setImage"; | ||
type: "imageAssigned"; | ||
image: import("@itk-viewer/io/MultiscaleSpatialImage.js").MultiscaleSpatialImage; | ||
@@ -198,3 +208,3 @@ } | { | ||
output: { | ||
bounds: import("@itk-viewer/io/types.js").Bounds; | ||
bounds: Bounds; | ||
toRendererCoordinateSystem: ReadonlyMat4; | ||
@@ -206,3 +216,11 @@ imageScale: number; | ||
type: "xstate.done.actor.updateImageScale"; | ||
output: number; | ||
output: { | ||
type: "noChange"; | ||
} | { | ||
type: "coarserScale"; | ||
imageScale: number; | ||
} | { | ||
type: "load"; | ||
imageScale: number; | ||
}; | ||
} | { | ||
@@ -213,7 +231,8 @@ type: "setResolution"; | ||
type: "setClipBounds"; | ||
clipBounds: import("@itk-viewer/io/types.js").Bounds; | ||
imageScale?: number | undefined; | ||
clipBounds: Bounds; | ||
} | { | ||
type: "setImageScale"; | ||
imageScale: number; | ||
} | { | ||
type: "sendCommands"; | ||
}, import("xstate").ProvidedActor, import("xstate").ParameterizedObject, import("xstate").ParameterizedObject, string, string>>>; | ||
@@ -227,3 +246,3 @@ export type RemoteActor = ReturnType<typeof createRemote>; | ||
type: "updateRenderer"; | ||
props: Partial<{ | ||
state: Partial<{ | ||
density: number; | ||
@@ -234,3 +253,3 @@ cameraPose: ReadonlyMat4; | ||
imageScale?: number | undefined; | ||
normalizedClipBounds: import("@itk-viewer/io/types.js").Bounds; | ||
normalizedClipBounds: Bounds; | ||
}>; | ||
@@ -240,3 +259,3 @@ } | { | ||
} | { | ||
type: "setImage"; | ||
type: "imageAssigned"; | ||
image: import("@itk-viewer/io/MultiscaleSpatialImage.js").MultiscaleSpatialImage; | ||
@@ -253,3 +272,3 @@ } | { | ||
output: { | ||
bounds: import("@itk-viewer/io/types.js").Bounds; | ||
bounds: Bounds; | ||
toRendererCoordinateSystem: ReadonlyMat4; | ||
@@ -261,3 +280,11 @@ imageScale: number; | ||
type: "xstate.done.actor.updateImageScale"; | ||
output: number; | ||
output: { | ||
type: "noChange"; | ||
} | { | ||
type: "coarserScale"; | ||
imageScale: number; | ||
} | { | ||
type: "load"; | ||
imageScale: number; | ||
}; | ||
} | { | ||
@@ -268,7 +295,8 @@ type: "setResolution"; | ||
type: "setClipBounds"; | ||
clipBounds: import("@itk-viewer/io/types.js").Bounds; | ||
imageScale?: number | undefined; | ||
clipBounds: Bounds; | ||
} | { | ||
type: "setImageScale"; | ||
imageScale: number; | ||
} | { | ||
type: "sendCommands"; | ||
}, import("xstate").ProvidedActor, import("xstate").ParameterizedObject, import("xstate").ParameterizedObject, string, string, { | ||
@@ -358,3 +386,3 @@ viewport: import("xstate").Actor<import("xstate").StateMachine<{ | ||
type: "updateRenderer"; | ||
props: Partial<{ | ||
state: Partial<{ | ||
density: number; | ||
@@ -365,3 +393,3 @@ cameraPose: ReadonlyMat4; | ||
imageScale?: number | undefined; | ||
normalizedClipBounds: import("@itk-viewer/io/types.js").Bounds; | ||
normalizedClipBounds: Bounds; | ||
}>; | ||
@@ -371,3 +399,3 @@ } | { | ||
} | { | ||
type: "setImage"; | ||
type: "imageAssigned"; | ||
image: import("@itk-viewer/io/MultiscaleSpatialImage.js").MultiscaleSpatialImage; | ||
@@ -384,3 +412,3 @@ } | { | ||
output: { | ||
bounds: import("@itk-viewer/io/types.js").Bounds; | ||
bounds: Bounds; | ||
toRendererCoordinateSystem: ReadonlyMat4; | ||
@@ -392,3 +420,11 @@ imageScale: number; | ||
type: "xstate.done.actor.updateImageScale"; | ||
output: number; | ||
output: { | ||
type: "noChange"; | ||
} | { | ||
type: "coarserScale"; | ||
imageScale: number; | ||
} | { | ||
type: "load"; | ||
imageScale: number; | ||
}; | ||
} | { | ||
@@ -399,7 +435,8 @@ type: "setResolution"; | ||
type: "setClipBounds"; | ||
clipBounds: import("@itk-viewer/io/types.js").Bounds; | ||
imageScale?: number | undefined; | ||
clipBounds: Bounds; | ||
} | { | ||
type: "setImageScale"; | ||
imageScale: number; | ||
} | { | ||
type: "sendCommands"; | ||
}, import("xstate").ProvidedActor, import("xstate").ParameterizedObject, import("xstate").ParameterizedObject, string, string>>>; | ||
@@ -406,0 +443,0 @@ viewport: import("xstate").ActorRef<{ |
@@ -6,2 +6,5 @@ import { createActor, fromPromise } from 'xstate'; | ||
import { remoteMachine } from './remote-machine.js'; | ||
import { XYZ } from '@itk-viewer/io/dimensionUtils.js'; | ||
import { worldBoundsToIndexBounds } from '@itk-viewer/io/MultiscaleSpatialImage.js'; | ||
import { transformBounds } from '@itk-viewer/io/transformBounds.js'; | ||
// Should match constant in agave-renderer::renderer.py | ||
@@ -36,2 +39,23 @@ const RENDERER_SERVICE_ID = 'agave-renderer'; | ||
}; | ||
const makeLoadImageCommand = ([type, payload], context) => { | ||
const { imageIndexClipBounds } = context; | ||
if (!imageIndexClipBounds) | ||
throw new Error('No image index clip bounds'); | ||
const image_path = type === 'image' ? payload : context.rendererState.image; | ||
const multiresolution_level = type === 'imageScale' ? payload : context.rendererState.imageScale; | ||
const channelRange = imageIndexClipBounds.get('c') ?? [0, 0]; | ||
const cDelta = channelRange[1] - channelRange[0]; | ||
const channels = Array.from({ length: cDelta + 1 }, // +1 for inclusive | ||
(_, i) => i + channelRange[0]); | ||
const region = XYZ.flatMap((dim) => imageIndexClipBounds.get(dim)); | ||
return [ | ||
'loadImage', | ||
{ | ||
image_path, | ||
multiresolution_level, | ||
channels, | ||
region, | ||
}, | ||
]; | ||
}; | ||
export const createHyphaMachineConfig = () => { | ||
@@ -42,4 +66,4 @@ let decodeWorker = null; | ||
connect: fromPromise(async ({ input }) => createHyphaRenderer(input.context)), | ||
renderer: fromPromise(async ({ input: { context, events } }) => { | ||
const commands = events | ||
commandSender: fromPromise(async ({ input: { context, commands } }) => { | ||
const { commands: translatedCommands } = commands | ||
.map(([key, value]) => { | ||
@@ -49,20 +73,9 @@ if (key === 'cameraPose') { | ||
} | ||
if (key === 'image') { | ||
const { imageScale: multiresolution_level } = context.rendererProps; | ||
return [ | ||
'loadImage', | ||
{ image_path: value, multiresolution_level }, | ||
]; | ||
if (key === 'image' || key === 'imageScale') { | ||
return makeLoadImageCommand([key, value], context); | ||
} | ||
if (key === 'imageScale') { | ||
const { image: image_path } = context.rendererProps; | ||
return [ | ||
'loadImage', | ||
{ image_path, multiresolution_level: value }, | ||
]; | ||
} | ||
return [key, value]; | ||
}) | ||
.flatMap((event) => { | ||
const [type] = event; | ||
.flatMap((command) => { | ||
const [type] = command; | ||
if (type === 'loadImage') { | ||
@@ -72,10 +85,24 @@ // Resend camera pose after load image. | ||
return [ | ||
event, | ||
makeCameraPoseCommand(context.toRendererCoordinateSystem, context.rendererProps.cameraPose), | ||
command, | ||
makeCameraPoseCommand(context.toRendererCoordinateSystem, context.rendererState.cameraPose), | ||
]; | ||
} | ||
return [event]; | ||
return [command]; | ||
}) | ||
.reduceRight( | ||
// filter duplicate commands | ||
({ commands, seenCommands }, command) => { | ||
const [type] = command; | ||
if (seenCommands.has(type)) { | ||
return { commands, seenCommands }; | ||
} | ||
seenCommands.add(type); | ||
return { commands: [command, ...commands], seenCommands }; | ||
}, { | ||
commands: [], | ||
seenCommands: new Set(), | ||
}); | ||
const { server } = context; | ||
server.updateRenderer(commands); | ||
context.server.updateRenderer(translatedCommands); | ||
}), | ||
renderer: fromPromise(async ({ input: { context: { server }, }, }) => { | ||
const { frame: encodedImage, renderTime } = await server.render(); | ||
@@ -87,6 +114,13 @@ const { image: frame, webWorker } = await decode(decodeWorker, encodedImage); | ||
// Computes world bounds and transform from VTK to Agave coordinate system | ||
imageProcessor: fromPromise(async ({ input: { image, imageScale } }) => { | ||
const bounds = await image.getWorldBounds(imageScale); | ||
imageProcessor: fromPromise(async ({ input: { image, imageScale, clipBounds } }) => { | ||
const indexToWorld = await image.scaleIndexToWorld(imageScale); | ||
const imageWorldToIndex = mat4.invert(mat4.create(), indexToWorld); | ||
const fullIndexBounds = image.getIndexBounds(imageScale); | ||
const byDim = worldBoundsToIndexBounds({ | ||
bounds: clipBounds, | ||
fullIndexBounds, | ||
worldToIndex: imageWorldToIndex, | ||
}); | ||
// loaded image world bounds | ||
const bounds = transformBounds(indexToWorld, XYZ.flatMap((dim) => byDim.get(dim))); | ||
// Remove image origin offset to world origin | ||
@@ -104,5 +138,6 @@ const imageOrigin = vec3.fromValues(bounds[0], bounds[2], bounds[4]); | ||
mat4.invert(transform, transform); | ||
const fullWorldBounds = await image.getWorldBounds(imageScale); | ||
return { | ||
toRendererCoordinateSystem: transform, | ||
bounds, | ||
bounds: fullWorldBounds, | ||
image, | ||
@@ -109,0 +144,0 @@ imageScale, |
{ | ||
"name": "@itk-viewer/remote-viewport", | ||
"version": "0.2.7", | ||
"version": "0.2.8", | ||
"description": "", | ||
@@ -24,8 +24,8 @@ "type": "module", | ||
"dependencies": { | ||
"@itk-wasm/htj2k": "^1.1.0", | ||
"@itk-wasm/htj2k": "2.1.0", | ||
"gl-matrix": "^3.4.3", | ||
"imjoy-rpc": "^0.5.44", | ||
"xstate": "5.0.0-beta.34", | ||
"@itk-viewer/io": "^0.1.4", | ||
"@itk-viewer/viewer": "^0.2.4" | ||
"@itk-viewer/io": "^0.1.5", | ||
"@itk-viewer/viewer": "^0.2.5" | ||
}, | ||
@@ -32,0 +32,0 @@ "scripts": { |
@@ -30,3 +30,3 @@ import { ReadonlyMat4, mat4 } from 'gl-matrix'; | ||
type RendererProps = { | ||
type RendererState = { | ||
density: number; | ||
@@ -49,3 +49,3 @@ cameraPose: ReadonlyMat4; | ||
// example: [['density', 30], ['cameraPose', mat4.create()]] | ||
export type RendererEntries = Entries<RendererProps>; | ||
export type RendererEntries = Entries<RendererState>; | ||
@@ -56,7 +56,8 @@ export type Context = { | ||
frame?: Image; | ||
rendererProps: RendererProps; | ||
queuedRendererEvents: RendererEntries; | ||
stagedRendererEvents: RendererEntries; | ||
rendererState: RendererState; | ||
queuedRendererCommands: RendererEntries; | ||
stagedRendererCommands: RendererEntries; | ||
viewport: ActorRefFrom<typeof viewportMachine>; | ||
maxImageBytes: number; | ||
imageScale: number; | ||
// computed image values | ||
@@ -66,3 +67,5 @@ toRendererCoordinateSystem: ReadonlyMat4; | ||
imageIndexClipBounds?: ReadOnlyDimensionBounds; | ||
loadedImageIndexBounds?: ReadOnlyDimensionBounds; | ||
clipBounds: Bounds; | ||
loadedImageClipBounds: Bounds; | ||
imageWorldToIndex: ReadonlyMat4; | ||
@@ -78,3 +81,3 @@ }; | ||
type: 'updateRenderer'; | ||
props: Partial<RendererProps>; | ||
state: Partial<RendererState>; | ||
}; | ||
@@ -86,4 +89,4 @@ | ||
type SetImage = { | ||
type: 'setImage'; | ||
type ImageAssigned = { | ||
type: 'imageAssigned'; | ||
image: MultiscaleSpatialImage; | ||
@@ -115,5 +118,10 @@ }; | ||
type UpdateImageScaleResult = | ||
| { type: 'noChange' } | ||
| { type: 'coarserScale'; imageScale: number } | ||
| { type: 'load'; imageScale: number }; | ||
type UpdateImageScaleDone = { | ||
type: 'xstate.done.actor.updateImageScale'; | ||
output: number; | ||
output: UpdateImageScaleResult; | ||
}; | ||
@@ -125,3 +133,3 @@ | ||
| RenderEvent | ||
| SetImage | ||
| ImageAssigned | ||
| SlowFps | ||
@@ -133,4 +141,5 @@ | FastFps | ||
| { type: 'setResolution'; resolution: [number, number] } | ||
| { type: 'setClipBounds'; clipBounds: Bounds; imageScale?: number } | ||
| { type: 'setImageScale'; imageScale: number }; | ||
| { type: 'setClipBounds'; clipBounds: Bounds } | ||
| { type: 'setImageScale'; imageScale: number } | ||
| { type: 'sendCommands' }; | ||
@@ -147,6 +156,6 @@ type ActionArgs = { event: Event; context: Context }; | ||
const image = getImage(context); | ||
if (context.rendererProps.imageScale === undefined) | ||
if (context.rendererState.imageScale === undefined) | ||
throw new Error('imageScale not found'); | ||
const currentScale = context.rendererProps.imageScale; | ||
const currentScale = context.rendererState.imageScale; | ||
const scaleChange = event.type === 'slowFps' ? 1 : -1; | ||
@@ -160,359 +169,439 @@ const targetScale = currentScale + scaleChange; | ||
targetScale: number, | ||
clipBounds: Bounds, | ||
) => { | ||
const voxelCount = await getVoxelCount(image, targetScale); | ||
const voxelCount = await getVoxelCount(image, targetScale, clipBounds); | ||
return getBytes(image, voxelCount); | ||
}; | ||
export const remoteMachine = createMachine({ | ||
types: {} as { | ||
context: Context; | ||
events: Event; | ||
}, | ||
id: 'remote', | ||
context: ({ input }: { input: { viewport: Viewport } }) => ({ | ||
rendererProps: { | ||
density: 30, | ||
cameraPose: mat4.create(), | ||
renderSize: [1, 1] as [number, number], | ||
normalizedClipBounds: [0, 1, 0, 1, 0, 1] as Bounds, | ||
}, | ||
queuedRendererEvents: [], | ||
stagedRendererEvents: [], | ||
const EPSILON = 0.000001; | ||
// computed async image values | ||
toRendererCoordinateSystem: mat4.create(), | ||
imageWorldBounds: createBounds(), | ||
clipBounds: createBounds(), | ||
imageWorldToIndex: mat4.create(), | ||
const checkBoundsBigger = ( | ||
fullImage: Bounds, | ||
benchmark: Bounds, | ||
sample: Bounds, | ||
) => { | ||
// clamp rendered bounds to max size of image | ||
sample.forEach((b, i) => { | ||
sample[i] = | ||
i % 2 | ||
? Math.min(b, fullImage[i]) // high bound case | ||
: Math.max(b, fullImage[i]); // low bound case | ||
}); | ||
maxImageBytes: MAX_IMAGE_BYTES_DEFAULT, | ||
...input, | ||
}), | ||
type: 'parallel', | ||
states: { | ||
imageProcessor: { | ||
initial: 'idle', | ||
on: { | ||
setImage: '.updatingScale', | ||
setImageScale: '.updatingScale', | ||
return benchmark.some((loaded, i) => { | ||
return i % 2 | ||
? sample[i] - loaded > EPSILON // high bound case: currentBounds[i] > loadedBound | ||
: loaded - sample[i] > EPSILON; // low bound case: currentBounds[i] < loadedBound | ||
}); | ||
}; | ||
export const remoteMachine = createMachine( | ||
{ | ||
types: {} as { | ||
context: Context; | ||
events: Event; | ||
}, | ||
id: 'remote', | ||
context: ({ input }: { input: { viewport: Viewport } }) => ({ | ||
rendererState: { | ||
density: 30, | ||
cameraPose: mat4.create(), | ||
renderSize: [1, 1] as [number, number], | ||
normalizedClipBounds: [0, 1, 0, 1, 0, 1] as Bounds, | ||
}, | ||
states: { | ||
idle: {}, | ||
updatingScale: { | ||
// Ensure imageScale is not the same as before and fits in memory | ||
id: 'updateImageScale', | ||
invoke: { | ||
input: ({ context, event }) => { | ||
const image = getImage(context); | ||
const getImageScale = () => { | ||
if (event.type === 'setImageScale') return event.imageScale; | ||
if (event.type === 'setImage') return image.coarsestScale; | ||
throw new Error('Unexpected event type: ' + event.type); | ||
}; | ||
const imageScale = getImageScale(); | ||
return { | ||
context, | ||
imageScale, | ||
}; | ||
}, | ||
src: fromPromise(async ({ input: { imageScale, context } }) => { | ||
const image = getImage(context); | ||
queuedRendererCommands: [], | ||
stagedRendererCommands: [], | ||
if (imageScale === context.rendererProps.imageScale) return; | ||
imageScale: 0, | ||
// computed async image values | ||
toRendererCoordinateSystem: mat4.create(), | ||
imageWorldBounds: createBounds(), | ||
clipBounds: createBounds(), | ||
loadedImageClipBounds: createBounds(), | ||
imageWorldToIndex: mat4.create(), | ||
const imageBytes = await computeBytes(image, imageScale); | ||
if (imageBytes > context.maxImageBytes) return; | ||
return imageScale; | ||
maxImageBytes: MAX_IMAGE_BYTES_DEFAULT, | ||
...input, | ||
}), | ||
type: 'parallel', | ||
states: { | ||
imageProcessor: { | ||
initial: 'idle', | ||
on: { | ||
imageAssigned: '.updatingScale', | ||
setImageScale: '.updatingScale', | ||
}, | ||
states: { | ||
idle: {}, | ||
raiseCoarserScale: { | ||
entry: raise(({ event }) => { | ||
const result = (event as UpdateImageScaleDone).output; | ||
if (result.type === 'coarserScale') { | ||
return { | ||
type: 'setImageScale' as const, | ||
imageScale: result.imageScale, | ||
}; | ||
} | ||
throw new Error('Unexpected result type: ' + result.type); | ||
}), | ||
onDone: { | ||
guard: ({ event }) => event.output !== undefined, | ||
target: 'updatingComputedValues', | ||
}, | ||
}, | ||
}, | ||
updatingComputedValues: { | ||
// For new image scale, compute imageWorldBounds, imageWorldToIndex, toRendererCoordinateSystem | ||
invoke: { | ||
src: 'imageProcessor', | ||
input: ({ context, event }) => { | ||
const image = getImage(context); | ||
const imageScale = (event as UpdateImageScaleDone).output; | ||
return { | ||
image, | ||
imageScale, | ||
}; | ||
}, | ||
onDone: { | ||
actions: [ | ||
assign({ | ||
toRendererCoordinateSystem: ({ | ||
event: { | ||
output: { toRendererCoordinateSystem }, | ||
}, | ||
}) => toRendererCoordinateSystem, | ||
imageWorldBounds: ({ | ||
event: { | ||
output: { bounds }, | ||
}, | ||
}) => bounds, | ||
imageWorldToIndex: ({ | ||
event: { | ||
output: { imageWorldToIndex }, | ||
}, | ||
}) => imageWorldToIndex, | ||
}), | ||
updatingScale: { | ||
// Ensure imageScale is not the same as before and fits in memory | ||
id: 'updateImageScale', | ||
invoke: { | ||
input: ({ context, event }) => { | ||
const image = getImage(context); | ||
const getImageScale = () => { | ||
if (event.type === 'setImageScale') return event.imageScale; | ||
if (event.type === 'imageAssigned') | ||
return image.coarsestScale; | ||
throw new Error('Unexpected event type: ' + event.type); | ||
}; | ||
const imageScale = getImageScale(); | ||
return { | ||
context, | ||
imageScale, | ||
}; | ||
}, | ||
src: fromPromise(async ({ input: { imageScale, context } }) => { | ||
const image = getImage(context); | ||
const boundsBiggerThanLoaded = checkBoundsBigger( | ||
context.imageWorldBounds, | ||
context.loadedImageClipBounds, | ||
context.clipBounds, | ||
); | ||
if ( | ||
!boundsBiggerThanLoaded && | ||
imageScale === context.imageScale | ||
) | ||
return { type: 'noChange' }; // no new data to load | ||
// Always try to load base scale | ||
if (imageScale !== image.coarsestScale) { | ||
const imageBytes = await computeBytes( | ||
image, | ||
imageScale, | ||
context.clipBounds, | ||
); | ||
if (imageBytes > context.maxImageBytes) | ||
return { type: 'coarserScale', imageScale: imageScale + 1 }; | ||
} | ||
return { type: 'load', imageScale }; | ||
}), | ||
onDone: [ | ||
{ | ||
guard: ({ event }) => event.output.type === 'coarserScale', | ||
target: 'raiseCoarserScale', | ||
}, | ||
{ | ||
guard: ({ event }) => event.output.type === 'load', | ||
target: 'updatingComputedValues', | ||
}, | ||
], | ||
target: 'checkingFirstImage', | ||
}, | ||
}, | ||
}, | ||
checkingFirstImage: { | ||
always: [ | ||
{ | ||
guard: ({ context }) => context.rendererProps.image === undefined, | ||
target: 'initClipBounds', | ||
}, | ||
{ target: 'sendingToRenderer' }, | ||
], | ||
}, | ||
initClipBounds: { | ||
entry: raise(({ event }) => { | ||
return { | ||
type: 'setClipBounds' as const, | ||
clipBounds: (event as ImageProcessorDone).output.bounds, | ||
imageScale: (event as ImageProcessorDone).output.imageScale, | ||
}; | ||
}), | ||
always: 'sendingToRenderer', | ||
}, | ||
sendingToRenderer: { | ||
entry: raise(({ event }) => { | ||
const { image, imageScale } = (event as ImageProcessorDone).output; | ||
return { | ||
type: 'updateRenderer' as const, | ||
props: { | ||
image: image.name, | ||
imageScale, | ||
}, | ||
}; | ||
}), | ||
}, | ||
}, | ||
}, | ||
// root state captures initial rendererProps events even when disconnected | ||
root: { | ||
entry: [ | ||
assign({ | ||
viewport: ({ spawn }) => spawn(viewportMachine, { id: 'viewport' }), | ||
}), | ||
], | ||
on: { | ||
updateRenderer: { | ||
actions: [ | ||
assign({ | ||
rendererProps: ({ event: { props }, context }) => { | ||
updatingComputedValues: { | ||
entry: [ | ||
assign({ | ||
imageScale: ({ event }) => { | ||
const result = (event as UpdateImageScaleDone).output; | ||
if (result.type === 'load') return result.imageScale; | ||
throw new Error('Unexpected result type: ' + result.type); | ||
}, | ||
}), | ||
], | ||
// For new image scale, compute imageWorldBounds, imageWorldToIndex, toRendererCoordinateSystem | ||
invoke: { | ||
src: 'imageProcessor', | ||
input: ({ context }) => { | ||
const image = getImage(context); | ||
const { imageScale, clipBounds } = context; | ||
return { | ||
...context.rendererProps, | ||
...props, | ||
image, | ||
imageScale, | ||
clipBounds, | ||
}; | ||
}, | ||
queuedRendererEvents: ({ event: { props }, context }) => [ | ||
...context.queuedRendererEvents, | ||
...(getEntries(props) as RendererEntries), | ||
], | ||
}), | ||
// Trigger a render (if in idle state) | ||
raise({ type: 'render' }), | ||
], | ||
}, | ||
cameraPoseUpdated: { | ||
actions: [ | ||
raise(({ event }) => { | ||
onDone: { | ||
actions: [ | ||
assign({ | ||
toRendererCoordinateSystem: ({ | ||
event: { | ||
output: { toRendererCoordinateSystem }, | ||
}, | ||
}) => toRendererCoordinateSystem, | ||
imageWorldBounds: ({ | ||
event: { | ||
output: { bounds }, | ||
}, | ||
}) => bounds, | ||
imageWorldToIndex: ({ | ||
event: { | ||
output: { imageWorldToIndex }, | ||
}, | ||
}) => imageWorldToIndex, | ||
}), | ||
'computeImageIndexClipBounds', | ||
assign({ | ||
loadedImageIndexBounds: ({ context }) => | ||
context.imageIndexClipBounds, | ||
loadedImageClipBounds: ({ context }) => context.clipBounds, | ||
}), | ||
], | ||
target: 'checkingFirstImage', | ||
}, | ||
}, | ||
}, | ||
checkingFirstImage: { | ||
always: [ | ||
{ | ||
guard: ({ context }) => | ||
context.rendererState.image === undefined, | ||
target: 'initClipBounds', | ||
}, | ||
{ target: 'sendingToRenderer' }, | ||
], | ||
}, | ||
initClipBounds: { | ||
entry: raise(({ event }) => { | ||
return { | ||
type: 'updateRenderer' as const, | ||
props: { cameraPose: event.pose }, | ||
type: 'setClipBounds' as const, | ||
clipBounds: (event as ImageProcessorDone).output.bounds, | ||
}; | ||
}), | ||
], | ||
always: 'sendingToRenderer', | ||
}, | ||
sendingToRenderer: { | ||
entry: [ | ||
raise(({ event }) => { | ||
const { image, imageScale } = (event as ImageProcessorDone) | ||
.output; | ||
return { | ||
type: 'updateRenderer' as const, | ||
state: { | ||
image: image.name, | ||
imageScale, | ||
}, | ||
}; | ||
}), | ||
'updateNormalizedClipBounds', | ||
], | ||
}, | ||
}, | ||
setResolution: { | ||
actions: [ | ||
raise(({ event }) => { | ||
return { | ||
type: 'updateRenderer' as const, | ||
props: { renderSize: event.resolution }, | ||
}; | ||
}), | ||
], | ||
}, | ||
setClipBounds: { | ||
actions: [ | ||
assign({ | ||
clipBounds: ({ event: { clipBounds } }) => clipBounds, | ||
}), | ||
raise( | ||
({ | ||
context: { | ||
viewport, | ||
clipBounds, | ||
imageWorldToIndex, | ||
rendererProps, | ||
}, | ||
// root state captures initial updateRenderer events, even when disconnected | ||
root: { | ||
entry: [ | ||
assign({ | ||
viewport: ({ spawn }) => spawn(viewportMachine, { id: 'viewport' }), | ||
}), | ||
], | ||
on: { | ||
updateRenderer: { | ||
actions: [ | ||
assign({ | ||
rendererState: ({ event: { state }, context }) => { | ||
return { | ||
...context.rendererState, | ||
...state, | ||
}; | ||
}, | ||
event, | ||
}) => { | ||
const imageScale = event.imageScale ?? rendererProps.imageScale; | ||
const { image } = viewport.getSnapshot().context; | ||
if (!image || imageScale === undefined) | ||
throw new Error('image or imageScale not found'); | ||
const fullIndexBounds = image.getIndexBounds(imageScale); | ||
const imageIndexClipBounds = worldBoundsToIndexBounds({ | ||
bounds: clipBounds, | ||
fullIndexBounds, | ||
worldToIndex: imageWorldToIndex, | ||
}); | ||
// Compute normalized bounds in image space | ||
const spatialImageBounds = ensuredDims( | ||
[0, 1], | ||
XYZ, | ||
fullIndexBounds, | ||
); | ||
const ranges = Object.fromEntries( | ||
XYZ.map((dim) => [dim, spatialImageBounds.get(dim)![1]]), | ||
); | ||
const normalizedClipBounds = XYZ.flatMap( | ||
(dim) => | ||
imageIndexClipBounds.get(dim)?.map((v) => v / ranges[dim]), | ||
) as Bounds; | ||
queuedRendererCommands: ({ event: { state }, context }) => [ | ||
...context.queuedRendererCommands, | ||
...(getEntries(state) as RendererEntries), | ||
], | ||
}), | ||
// Trigger a render (if in idle state) | ||
raise({ type: 'sendCommands' }), | ||
], | ||
}, | ||
cameraPoseUpdated: { | ||
actions: [ | ||
raise(({ event }) => { | ||
return { | ||
type: 'updateRenderer' as const, | ||
props: { normalizedClipBounds }, | ||
state: { cameraPose: event.pose }, | ||
}; | ||
}), | ||
], | ||
}, | ||
setResolution: { | ||
actions: [ | ||
raise(({ event }) => { | ||
return { | ||
type: 'updateRenderer' as const, | ||
state: { renderSize: event.resolution }, | ||
}; | ||
}), | ||
], | ||
}, | ||
setClipBounds: { | ||
actions: [ | ||
assign({ | ||
clipBounds: ({ event: { clipBounds } }) => clipBounds, | ||
}), | ||
'computeImageIndexClipBounds', | ||
'updateNormalizedClipBounds', | ||
raise(({ context }) => { | ||
return { | ||
type: 'setImageScale' as const, | ||
imageScale: context.imageScale, | ||
}; | ||
}), | ||
], | ||
}, | ||
}, | ||
initial: 'disconnected', | ||
states: { | ||
disconnected: { | ||
on: { | ||
connect: { | ||
actions: [ | ||
assign({ | ||
serverConfig: ({ | ||
event: { config }, | ||
}: { | ||
event: ConnectEvent; | ||
}) => { | ||
return config; | ||
}, | ||
}), | ||
], | ||
target: 'connecting', | ||
}, | ||
), | ||
], | ||
}, | ||
}, | ||
initial: 'disconnected', | ||
states: { | ||
disconnected: { | ||
on: { | ||
connect: { | ||
actions: [ | ||
assign({ | ||
serverConfig: ({ | ||
event: { config }, | ||
}: { | ||
event: ConnectEvent; | ||
}) => { | ||
return config; | ||
}, | ||
}), | ||
], | ||
target: 'connecting', | ||
}, | ||
}, | ||
}, | ||
connecting: { | ||
invoke: { | ||
id: 'connect', | ||
src: 'connect', | ||
input: ({ context, event }) => ({ | ||
context, | ||
event, | ||
}), | ||
onDone: { | ||
actions: assign({ | ||
server: ({ event }) => event.output, | ||
// initially, send all props to renderer | ||
queuedRendererEvents: ({ context }) => | ||
getEntries(context.rendererProps), | ||
connecting: { | ||
invoke: { | ||
id: 'connect', | ||
src: 'connect', | ||
input: ({ context, event }) => ({ | ||
context, | ||
event, | ||
}), | ||
target: 'online', | ||
onDone: { | ||
actions: assign({ | ||
server: ({ event }) => event.output, | ||
// initially, send all commands to renderer | ||
queuedRendererCommands: ({ context }) => | ||
getEntries(context.rendererState), | ||
}), | ||
target: 'online', | ||
}, | ||
}, | ||
}, | ||
}, | ||
online: { | ||
type: 'parallel', | ||
states: { | ||
fpsWatcher: { | ||
invoke: { | ||
id: 'fpsWatcher', | ||
src: fpsWatcher, | ||
online: { | ||
type: 'parallel', | ||
states: { | ||
fpsWatcher: { | ||
invoke: { | ||
id: 'fpsWatcher', | ||
src: fpsWatcher, | ||
}, | ||
}, | ||
}, | ||
imageScaleUpdater: { | ||
on: { | ||
slowFps: { | ||
actions: raise(({ context, event }) => { | ||
return { | ||
type: 'setImageScale' as const, | ||
imageScale: getTargetScale({ context, event }), | ||
}; | ||
}), | ||
imageScaleUpdater: { | ||
on: { | ||
slowFps: { | ||
actions: raise(({ context, event }) => { | ||
return { | ||
type: 'setImageScale' as const, | ||
imageScale: getTargetScale({ context, event }), | ||
}; | ||
}), | ||
}, | ||
fastFps: { | ||
actions: raise(({ context, event }) => { | ||
return { | ||
type: 'setImageScale' as const, | ||
imageScale: getTargetScale({ context, event }), | ||
}; | ||
}), | ||
}, | ||
}, | ||
fastFps: { | ||
actions: raise(({ context, event }) => { | ||
return { | ||
type: 'setImageScale' as const, | ||
imageScale: getTargetScale({ context, event }), | ||
}; | ||
}), | ||
}, | ||
}, | ||
}, | ||
renderLoop: { | ||
initial: 'render', | ||
states: { | ||
render: { | ||
invoke: { | ||
id: 'renderer', | ||
src: 'renderer', | ||
input: ({ context }: { context: Context }) => ({ | ||
events: [...context.stagedRendererEvents], | ||
context, | ||
}), | ||
onDone: { | ||
actions: [ | ||
assign({ | ||
frame: ({ event }) => { | ||
return event.output.frame; | ||
}, | ||
}), | ||
sendTo('fpsWatcher', ({ event }) => ({ | ||
type: 'newSample', | ||
renderTime: event.output.renderTime, | ||
})), | ||
], | ||
target: 'idle', | ||
commandLoop: { | ||
initial: 'sendingCommands', | ||
states: { | ||
sendingCommands: { | ||
invoke: { | ||
id: 'commandSender', | ||
src: 'commandSender', | ||
input: ({ context }: { context: Context }) => ({ | ||
commands: [...context.stagedRendererCommands], | ||
context, | ||
}), | ||
onDone: { | ||
target: 'idle', | ||
}, | ||
onError: { | ||
actions: (e) => | ||
console.error( | ||
`Error while sending commands.`, | ||
(e.event.data as Error).stack ?? e.event.data, | ||
), | ||
target: 'idle', // soldier on | ||
}, | ||
}, | ||
onError: { | ||
actions: (e) => | ||
console.error( | ||
`Error while updating renderer.`, | ||
(e.event.data as Error).stack ?? e.event.data, | ||
), | ||
target: 'idle', // soldier on | ||
}, | ||
idle: { | ||
always: { | ||
// More commands while sending commands? Then send commands. | ||
guard: ({ context }) => | ||
context.queuedRendererCommands.length > 0, | ||
target: 'sendingCommands', | ||
}, | ||
on: { | ||
sendCommands: { target: 'sendingCommands' }, | ||
}, | ||
exit: assign({ | ||
// consumes queue in prep for renderer | ||
stagedRendererCommands: ({ context }) => [ | ||
...context.queuedRendererCommands, | ||
], | ||
queuedRendererCommands: [], | ||
}), | ||
}, | ||
}, | ||
idle: { | ||
always: { | ||
// Renderer props changed while rendering? Then render. | ||
guard: ({ context }) => | ||
context.queuedRendererEvents.length > 0, | ||
target: 'render', | ||
}, | ||
renderLoop: { | ||
initial: 'render', | ||
states: { | ||
render: { | ||
invoke: { | ||
id: 'renderer', | ||
src: 'renderer', | ||
input: ({ context }: { context: Context }) => ({ | ||
context, | ||
}), | ||
onDone: { | ||
actions: [ | ||
assign({ | ||
frame: ({ event }) => { | ||
return event.output.frame; | ||
}, | ||
}), | ||
sendTo('fpsWatcher', ({ event }) => ({ | ||
type: 'newSample', | ||
renderTime: event.output.renderTime, | ||
})), | ||
], | ||
target: 'idle', | ||
}, | ||
onError: { | ||
actions: (e) => | ||
console.error( | ||
`Error while updating renderer.`, | ||
(e.event.data as Error).stack ?? e.event.data, | ||
), | ||
target: 'idle', // soldier on | ||
}, | ||
}, | ||
}, | ||
on: { | ||
render: { target: 'render' }, | ||
idle: { | ||
on: { | ||
render: { target: 'render' }, | ||
}, | ||
}, | ||
exit: assign({ | ||
// consumes queue in prep for renderer | ||
stagedRendererEvents: ({ context }) => [ | ||
...context.queuedRendererEvents, | ||
], | ||
queuedRendererEvents: [], | ||
}), | ||
}, | ||
@@ -526,2 +615,57 @@ }, | ||
}, | ||
}); | ||
{ | ||
actions: { | ||
computeImageIndexClipBounds: assign({ | ||
imageIndexClipBounds: ({ | ||
context: { viewport, imageWorldToIndex, clipBounds, imageScale }, | ||
}) => { | ||
const { image } = viewport.getSnapshot().context; | ||
if (!image || imageScale === undefined) | ||
throw new Error('image or imageScale not found'); | ||
const fullIndexBounds = image.getIndexBounds(imageScale); | ||
return worldBoundsToIndexBounds({ | ||
bounds: clipBounds, | ||
fullIndexBounds, | ||
worldToIndex: imageWorldToIndex, | ||
}); | ||
}, | ||
}), | ||
updateNormalizedClipBounds: raise( | ||
({ | ||
context: { | ||
viewport, | ||
imageIndexClipBounds, | ||
loadedImageIndexBounds, | ||
imageScale, | ||
}, | ||
}) => { | ||
const { image } = viewport.getSnapshot().context; | ||
if (!image || imageScale === undefined) | ||
throw new Error('image or imageScale not found'); | ||
if (!imageIndexClipBounds) | ||
throw new Error('imageIndexClipBounds not found'); | ||
if (!loadedImageIndexBounds) | ||
throw new Error('loadedImageIndexBounds not found'); | ||
// Compute normalized bounds in loaded image space | ||
const spatialImageBounds = ensuredDims( | ||
[0, 1], | ||
XYZ, | ||
loadedImageIndexBounds, | ||
); | ||
const normalizedClipBounds = XYZ.flatMap((dim) => { | ||
const [floor, top] = spatialImageBounds.get(dim)!; | ||
const range = top - floor; | ||
return imageIndexClipBounds | ||
.get(dim)! | ||
.map((v) => (v - floor) / range); | ||
}) as Bounds; | ||
return { | ||
type: 'updateRenderer' as const, | ||
state: { normalizedClipBounds }, | ||
}; | ||
}, | ||
), | ||
}, | ||
}, | ||
); |
@@ -6,2 +6,6 @@ import { createActor, fromPromise } from 'xstate'; | ||
import { RendererEntries, remoteMachine, Context } from './remote-machine.js'; | ||
import { XYZ } from '@itk-viewer/io/dimensionUtils.js'; | ||
import { worldBoundsToIndexBounds } from '@itk-viewer/io/MultiscaleSpatialImage.js'; | ||
import { Bounds } from '@itk-viewer/io/types.js'; | ||
import { transformBounds } from '@itk-viewer/io/transformBounds.js'; | ||
@@ -27,3 +31,3 @@ export type { Image } from '@itk-wasm/htj2k'; | ||
context: MachineContext; | ||
events: RendererEntries; | ||
commands: RendererEntries; | ||
}; | ||
@@ -73,2 +77,4 @@ | ||
type Command = [string, unknown]; | ||
const makeCameraPoseCommand = ( | ||
@@ -84,5 +90,29 @@ toRendererCoordinateSystem: ReadonlyMat4, | ||
); | ||
return ['cameraPose', mat4ToLookAt(transform)]; | ||
return ['cameraPose', mat4ToLookAt(transform)] as Command; | ||
}; | ||
const makeLoadImageCommand = ([type, payload]: Command, context: Context) => { | ||
const { imageIndexClipBounds } = context; | ||
if (!imageIndexClipBounds) throw new Error('No image index clip bounds'); | ||
const image_path = type === 'image' ? payload : context.rendererState.image; | ||
const multiresolution_level = | ||
type === 'imageScale' ? payload : context.rendererState.imageScale; | ||
const channelRange = imageIndexClipBounds.get('c') ?? [0, 0]; | ||
const cDelta = channelRange[1] - channelRange[0]; | ||
const channels = Array.from( | ||
{ length: cDelta + 1 }, // +1 for inclusive | ||
(_, i) => i + channelRange[0], | ||
); | ||
const region = XYZ.flatMap((dim) => imageIndexClipBounds.get(dim)); | ||
return [ | ||
'loadImage', | ||
{ | ||
image_path, | ||
multiresolution_level, | ||
channels, | ||
region, | ||
}, | ||
] as Command; | ||
}; | ||
export const createHyphaMachineConfig: () => RemoteMachineOptions = () => { | ||
@@ -96,5 +126,5 @@ let decodeWorker: Worker | null = null; | ||
), | ||
renderer: fromPromise( | ||
async ({ input: { context, events } }: { input: RendererInput }) => { | ||
const commands = events | ||
commandSender: fromPromise( | ||
async ({ input: { context, commands } }: { input: RendererInput }) => { | ||
const { commands: translatedCommands } = commands | ||
.map(([key, value]) => { | ||
@@ -107,24 +137,9 @@ if (key === 'cameraPose') { | ||
} | ||
if (key === 'image') { | ||
const { imageScale: multiresolution_level } = | ||
context.rendererProps; | ||
return [ | ||
'loadImage', | ||
{ image_path: value, multiresolution_level }, | ||
]; | ||
if (key === 'image' || key === 'imageScale') { | ||
return makeLoadImageCommand([key, value], context); | ||
} | ||
if (key === 'imageScale') { | ||
const { image: image_path } = context.rendererProps; | ||
return [ | ||
'loadImage', | ||
{ image_path, multiresolution_level: value }, | ||
]; | ||
} | ||
return [key, value]; | ||
}) | ||
.flatMap((event) => { | ||
const [type] = event; | ||
.flatMap((command) => { | ||
const [type] = command; | ||
if (type === 'loadImage') { | ||
@@ -134,14 +149,36 @@ // Resend camera pose after load image. | ||
return [ | ||
event, | ||
command as Command, | ||
makeCameraPoseCommand( | ||
context.toRendererCoordinateSystem, | ||
context.rendererProps.cameraPose, | ||
context.rendererState.cameraPose, | ||
), | ||
]; | ||
} | ||
return [event]; | ||
}); | ||
return [command as Command]; | ||
}) | ||
.reduceRight( | ||
// filter duplicate commands | ||
({ commands, seenCommands }, command) => { | ||
const [type] = command; | ||
if (seenCommands.has(type)) { | ||
return { commands, seenCommands }; | ||
} | ||
seenCommands.add(type); | ||
return { commands: [command, ...commands], seenCommands }; | ||
}, | ||
{ | ||
commands: [] as Array<Command>, | ||
seenCommands: new Set<string>(), | ||
}, | ||
); | ||
const { server } = context; | ||
server.updateRenderer(commands); | ||
context.server.updateRenderer(translatedCommands); | ||
}, | ||
), | ||
renderer: fromPromise( | ||
async ({ | ||
input: { | ||
context: { server }, | ||
}, | ||
}) => { | ||
const { frame: encodedImage, renderTime } = await server.render(); | ||
@@ -158,31 +195,45 @@ const { image: frame, webWorker } = await decode( | ||
// Computes world bounds and transform from VTK to Agave coordinate system | ||
imageProcessor: fromPromise(async ({ input: { image, imageScale } }) => { | ||
const bounds = await image.getWorldBounds(imageScale); | ||
imageProcessor: fromPromise( | ||
async ({ input: { image, imageScale, clipBounds } }) => { | ||
const indexToWorld = await image.scaleIndexToWorld(imageScale); | ||
const imageWorldToIndex = mat4.invert(mat4.create(), indexToWorld); | ||
const indexToWorld = await image.scaleIndexToWorld(imageScale); | ||
const imageWorldToIndex = mat4.invert(mat4.create(), indexToWorld); | ||
const fullIndexBounds = image.getIndexBounds(imageScale); | ||
const byDim = worldBoundsToIndexBounds({ | ||
bounds: clipBounds, | ||
fullIndexBounds, | ||
worldToIndex: imageWorldToIndex, | ||
}); | ||
// Remove image origin offset to world origin | ||
const imageOrigin = vec3.fromValues(bounds[0], bounds[2], bounds[4]); | ||
const transform = mat4.fromTranslation(mat4.create(), imageOrigin); | ||
// loaded image world bounds | ||
const bounds = transformBounds( | ||
indexToWorld, | ||
XYZ.flatMap((dim) => byDim.get(dim)) as Bounds, | ||
); | ||
// match Agave by normalizing to largest dim | ||
const wx = bounds[1] - bounds[0]; | ||
const wy = bounds[3] - bounds[2]; | ||
const wz = bounds[5] - bounds[4]; | ||
const maxDim = Math.max(wx, wy, wz); | ||
const scale = vec3.fromValues(maxDim, maxDim, maxDim); | ||
mat4.scale(transform, transform, scale); | ||
// Remove image origin offset to world origin | ||
const imageOrigin = vec3.fromValues(bounds[0], bounds[2], bounds[4]); | ||
const transform = mat4.fromTranslation(mat4.create(), imageOrigin); | ||
// invert to go from VTK to Agave | ||
mat4.invert(transform, transform); | ||
// match Agave by normalizing to largest dim | ||
const wx = bounds[1] - bounds[0]; | ||
const wy = bounds[3] - bounds[2]; | ||
const wz = bounds[5] - bounds[4]; | ||
const maxDim = Math.max(wx, wy, wz); | ||
const scale = vec3.fromValues(maxDim, maxDim, maxDim); | ||
mat4.scale(transform, transform, scale); | ||
return { | ||
toRendererCoordinateSystem: transform, | ||
bounds, | ||
image, | ||
imageScale, | ||
imageWorldToIndex, | ||
}; | ||
}), | ||
// invert to go from VTK to Agave | ||
mat4.invert(transform, transform); | ||
const fullWorldBounds = await image.getWorldBounds(imageScale); | ||
return { | ||
toRendererCoordinateSystem: transform, | ||
bounds: fullWorldBounds, | ||
image, | ||
imageScale, | ||
imageWorldToIndex, | ||
}; | ||
}, | ||
), | ||
}, | ||
@@ -189,0 +240,0 @@ }; |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
200902
2241
+ Added@itk-wasm/htj2k@2.1.0(transitive)
- Removed@itk-wasm/htj2k@1.1.4(transitive)
Updated@itk-viewer/io@^0.1.5
Updated@itk-viewer/viewer@^0.2.5
Updated@itk-wasm/htj2k@2.1.0