From f68e400783425110464a6cc195196c53a9b3c082 Mon Sep 17 00:00:00 2001 From: Forrest Date: Tue, 16 Jan 2024 12:04:28 -0500 Subject: [PATCH 1/4] feat(VolumeRepresentation): initial add --- .eslintrc | 4 +- src/core/VolumeRepresentation.tsx | 233 +++++++++++++++++++++++ src/core/modules/useDataRange.ts | 26 +++ src/core/modules/usePiecewiseFunction.ts | 42 ++++ src/index.ts | 1 + 5 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 src/core/VolumeRepresentation.tsx create mode 100644 src/core/modules/useDataRange.ts create mode 100644 src/core/modules/usePiecewiseFunction.ts diff --git a/.eslintrc b/.eslintrc index 148f06a..843e4fd 100644 --- a/.eslintrc +++ b/.eslintrc @@ -32,6 +32,8 @@ "react/jsx-handler-names": 0, "react/jsx-fragments": 0, "react/no-unused-prop-types": 0, - "import/export": 0 + "import/export": 0, + "n/no-callback-literal": 0, + "@typescript-eslint/no-explicit-any": 0 } } diff --git a/src/core/VolumeRepresentation.tsx b/src/core/VolumeRepresentation.tsx new file mode 100644 index 0000000..f5b7006 --- /dev/null +++ b/src/core/VolumeRepresentation.tsx @@ -0,0 +1,233 @@ +import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; +import vtkVolume, { + IVolumeInitialValues, +} from '@kitware/vtk.js/Rendering/Core/Volume'; +import vtkVolumeMapper, { + IVolumeMapperInitialValues, +} from '@kitware/vtk.js/Rendering/Core/VolumeMapper'; +import { IVolumePropertyInitialValues } from '@kitware/vtk.js/Rendering/Core/VolumeProperty'; +import { Vector2 } from '@kitware/vtk.js/types'; +import { + forwardRef, + PropsWithChildren, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useState, +} from 'react'; +import { IDownstream, IRepresentation } from '../types'; +import { compareShallowObject } from '../utils/comparators'; +import useBooleanAccumulator from '../utils/useBooleanAccumulator'; +import useComparableEffect from '../utils/useComparableEffect'; +import useLatest from '../utils/useLatest'; +import { + DownstreamContext, + RepresentationContext, + useRendererContext, +} from './contexts'; +import useColorTransferFunction from './modules/useColorTransferFunction'; +import useDataRange from './modules/useDataRange'; +import useMapper from './modules/useMapper'; +import usePiecewiseFunction from './modules/usePiecewiseFunction'; +import useProp from './modules/useProp'; + +export interface VolumeRepresentationProps extends PropsWithChildren { + /** + * The ID used to identify this component. + */ + id?: string; + + /** + * Properties to set to the mapper + */ + mapper?: IVolumeMapperInitialValues; + + /** + * An opational mapper instanc + */ + mapperInstance?: vtkVolumeMapper; + + /** + * Properties to set to the volume actor + */ + actor?: IVolumeInitialValues; + + /** + * Properties to set to the volume.property + */ + property?: IVolumePropertyInitialValues; + + /** + * Preset name for the lookup table color map + */ + colorMapPreset?: string; + + /** + * Data range use for the colorMap + */ + colorDataRange?: 'auto' | Vector2; + + /** + * Event callback for when data is made available. + * + * By the time this callback is invoked, you can be sure that: + * - the mapper has the input data + * - the actor is visible (unless explicitly marked as not visible) + * - initial properties are set + */ + onDataAvailable?: () => void; +} + +const DefaultProps = { + colorMapPreset: 'erdc_rainbow_bright', + colorDataRange: 'auto' as const, +}; + +export default forwardRef(function VolumeRepresentation( + props: VolumeRepresentationProps, + fwdRef +) { + const [modifiedRef, trackModified, resetModified] = useBooleanAccumulator(); + const [dataAvailable, setDataAvailable] = useState(false); + + // --- mapper --- // + + const getInternalMapper = useMapper( + () => vtkVolumeMapper.newInstance(), + props.mapper, + trackModified + ); + + const { mapperInstance } = props; + const getMapper = useCallback(() => { + if (mapperInstance) { + return mapperInstance; + } + return getInternalMapper(); + }, [mapperInstance, getInternalMapper]); + + // --- data range --- // + + const getDataArray = useCallback( + () => + getMapper()?.getInputData()?.getPointData().getScalars() as + | vtkDataArray + | undefined, + [getMapper] + ); + + const { dataRange, updateDataRange } = useDataRange(getDataArray); + + const rangeFromProps = props.colorDataRange ?? DefaultProps.colorDataRange; + const colorDataRange = rangeFromProps === 'auto' ? dataRange : rangeFromProps; + + // --- LUT --- // + + const getLookupTable = useColorTransferFunction( + props.colorMapPreset ?? DefaultProps.colorMapPreset, + colorDataRange, + trackModified + ); + + // --- PWF --- // + + const getPiecewiseFunction = usePiecewiseFunction( + colorDataRange, + trackModified + ); + + // --- actor --- // + + const actorProps = { + ...props.actor, + visibility: dataAvailable && (props.actor?.visibility ?? true), + }; + const getActor = useProp({ + constructor: () => vtkVolume.newInstance(), + id: props.id, + props: actorProps, + trackModified, + }); + + useEffect(() => { + getActor().setMapper(getMapper()); + }, [getActor, getMapper]); + + useEffect(() => { + getActor().getProperty().setRGBTransferFunction(0, getLookupTable()); + getActor().getProperty().setScalarOpacity(0, getPiecewiseFunction()); + getActor().getProperty().setInterpolationTypeToLinear(); + }, [getActor, getLookupTable, getPiecewiseFunction]); + + // set actor property props + const { property: propertyProps } = props; + useComparableEffect( + () => { + if (!propertyProps) return; + trackModified(getActor().getProperty().set(propertyProps)); + }, + [propertyProps], + ([cur], [prev]) => compareShallowObject(cur, prev) + ); + + // --- events --- // + + const onDataAvailable = useLatest(props.onDataAvailable); + useEffect(() => { + if (dataAvailable) { + // trigger onDataAvailable after making updates to the actor and mapper + onDataAvailable.current?.(); + } + // onDataAvailable is a ref + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dataAvailable]); + + // --- // + + const renderer = useRendererContext(); + + useEffect(() => { + if (modifiedRef.current) { + renderer.requestRender(); + resetModified(); + } + }); + + const representation = useMemo( + () => ({ + dataChanged: () => { + updateDataRange(); + renderer.requestRender(); + }, + dataAvailable: (available = true) => { + setDataAvailable(available); + representation.dataChanged(); + }, + getActor, + getMapper, + getData: () => { + return getMapper().getInputData(); + }, + }), + [renderer, updateDataRange, getActor, getMapper] + ); + + const downstream = useMemo( + () => ({ + setInputData: (...args) => getMapper().setInputData(...args), + setInputConnection: (...args) => getMapper().setInputConnection(...args), + }), + [getMapper] + ); + + useImperativeHandle(fwdRef, () => representation); + + return ( + + + {props.children} + + + ); +}); diff --git a/src/core/modules/useDataRange.ts b/src/core/modules/useDataRange.ts new file mode 100644 index 0000000..3d84e2e --- /dev/null +++ b/src/core/modules/useDataRange.ts @@ -0,0 +1,26 @@ +import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; +import { useCallback, useEffect, useState } from 'react'; + +const DEFAULT_RANGE: [number, number] = [0, 1]; + +export default function useDataRange( + getDataArray: () => vtkDataArray | null | undefined, + defaultRange = DEFAULT_RANGE +) { + const [dataRange, setRange] = useState<[number, number]>(defaultRange); + + const updateDataRange = useCallback(() => { + const range = getDataArray()?.getRange(); + if (!range) return; + setRange(range); + }, [getDataArray]); + + useEffect(() => { + updateDataRange(); + }, [updateDataRange]); + + return { + dataRange, + updateDataRange, + }; +} diff --git a/src/core/modules/usePiecewiseFunction.ts b/src/core/modules/usePiecewiseFunction.ts new file mode 100644 index 0000000..272de0b --- /dev/null +++ b/src/core/modules/usePiecewiseFunction.ts @@ -0,0 +1,42 @@ +import vtkPiecewiseFunction from '@kitware/vtk.js/Common/DataModel/PiecewiseFunction'; +import { Vector2 } from '@kitware/vtk.js/types'; +import { compareVector2 } from '../../utils/comparators'; +import deletionRegistry from '../../utils/DeletionRegistry'; +import { BooleanAccumulator } from '../../utils/useBooleanAccumulator'; +import useComparableEffect from '../../utils/useComparableEffect'; +import useGetterRef from '../../utils/useGetterRef'; +import useUnmount from '../../utils/useUnmount'; + +export default function usePiecewiseFunction( + range: Vector2, + trackModified: BooleanAccumulator +) { + const [pwfRef, getPWF] = useGetterRef(() => { + const func = vtkPiecewiseFunction.newInstance(); + deletionRegistry.register(func, () => func.delete()); + return func; + }); + + useComparableEffect( + () => { + if (!range) return; + const pwf = getPWF(); + pwf.setNodes([ + { x: range[0], y: 0, midpoint: 0.5, sharpness: 0 }, + { x: range[1], y: 1, midpoint: 0.5, sharpness: 0 }, + ]); + trackModified(true); + }, + [range] as const, + ([curRange], [oldRange]) => compareVector2(curRange, oldRange) + ); + + useUnmount(() => { + if (pwfRef.current) { + deletionRegistry.markForDeletion(pwfRef.current); + pwfRef.current = null; + } + }); + + return getPWF; +} diff --git a/src/index.ts b/src/index.ts index 24ac272..d305c46 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,3 +44,4 @@ export { default as SliceRepresentation } from './core/SliceRepresentation'; export type { SliceRepresentationProps } from './core/SliceRepresentation'; export { default as View } from './core/View'; export type { ViewProps } from './core/View'; +export { default as VolumeRepresentation } from './core/VolumeRepresentation'; From 7ed06f4b3f30e7d560638c63d7e00f684c5dfc53 Mon Sep 17 00:00:00 2001 From: Forrest Date: Tue, 16 Jan 2024 12:53:49 -0500 Subject: [PATCH 2/4] feat: onDataAvailable/Changed event hooks --- src/core/Geometry2DRepresentation.tsx | 33 ++++++++++++++----- src/core/GeometryRepresentation.tsx | 33 ++++++++++++++----- src/core/ShareDataSet.tsx | 13 ++++++-- src/core/SliceRepresentation.tsx | 32 ++++++++++++++----- src/core/VolumeRepresentation.tsx | 46 ++++++++++++++++++++------- src/core/modules/useDataEvents.ts | 26 +++++++++++++++ src/types.ts | 2 ++ src/utils/createEvent.ts | 30 +++++++++++++++++ 8 files changed, 177 insertions(+), 38 deletions(-) create mode 100644 src/core/modules/useDataEvents.ts create mode 100644 src/utils/createEvent.ts diff --git a/src/core/Geometry2DRepresentation.tsx b/src/core/Geometry2DRepresentation.tsx index a19c772..5ef07fb 100644 --- a/src/core/Geometry2DRepresentation.tsx +++ b/src/core/Geometry2DRepresentation.tsx @@ -1,3 +1,4 @@ +import vtkPolyData from '@kitware/vtk.js/Common/DataModel/PolyData'; import vtkActor2D, { IActor2DInitialValues, } from '@kitware/vtk.js/Rendering/Core/Actor2D'; @@ -11,6 +12,7 @@ import { Vector2 } from '@kitware/vtk.js/types'; import { forwardRef, PropsWithChildren, + useCallback, useEffect, useImperativeHandle, useMemo, @@ -20,7 +22,6 @@ import { IDownstream, IRepresentation } from '../types'; import { compareShallowObject } from '../utils/comparators'; import useBooleanAccumulator from '../utils/useBooleanAccumulator'; import useComparableEffect from '../utils/useComparableEffect'; -import useLatest from '../utils/useLatest'; import { DownstreamContext, RepresentationContext, @@ -28,6 +29,7 @@ import { } from './contexts'; import useColorTransferFunction from './modules/useColorTransferFunction'; import useCoordinate from './modules/useCoordinate'; +import useDataEvents from './modules/useDataEvents'; import useMapper from './modules/useMapper'; import useProp from './modules/useProp'; @@ -120,6 +122,11 @@ export default forwardRef(function Geometry2DRepresentation( trackModified ); + const getInputData = useCallback( + () => getMapper().getInputData(), + [getMapper] + ); + // --- actor --- // const actorProps = { @@ -154,15 +161,15 @@ export default forwardRef(function Geometry2DRepresentation( // --- events --- // - const onDataAvailable = useLatest(props.onDataAvailable); + const { dataChangedEvent, dataAvailableEvent } = + useDataEvents(props); + + // trigger data available event useEffect(() => { if (dataAvailable) { - // trigger onDataAvailable after making updates to the actor and mapper - onDataAvailable.current?.(); + dataAvailableEvent.current.dispatchEvent(getInputData()); } - // onDataAvailable is a ref - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dataAvailable]); + }, [dataAvailable, dataAvailableEvent, getInputData]); // --- // @@ -178,6 +185,7 @@ export default forwardRef(function Geometry2DRepresentation( const representation = useMemo( () => ({ dataChanged: () => { + dataChangedEvent.current.dispatchEvent(getInputData()); renderer.requestRender(); }, dataAvailable: (available = true) => { @@ -186,8 +194,17 @@ export default forwardRef(function Geometry2DRepresentation( }, getActor, getMapper, + onDataAvailable: (cb) => dataAvailableEvent.current.addEventListener(cb), + onDataChanged: (cb) => dataChangedEvent.current.addEventListener(cb), }), - [renderer, getActor, getMapper] + [ + renderer, + getActor, + getMapper, + getInputData, + dataAvailableEvent, + dataChangedEvent, + ] ); const downstream = useMemo( diff --git a/src/core/GeometryRepresentation.tsx b/src/core/GeometryRepresentation.tsx index 9b1188f..1f18c50 100644 --- a/src/core/GeometryRepresentation.tsx +++ b/src/core/GeometryRepresentation.tsx @@ -1,3 +1,4 @@ +import vtkPolyData from '@kitware/vtk.js/Common/DataModel/PolyData'; import vtkActor, { IActorInitialValues, } from '@kitware/vtk.js/Rendering/Core/Actor'; @@ -9,6 +10,7 @@ import { Vector2 } from '@kitware/vtk.js/types'; import { forwardRef, PropsWithChildren, + useCallback, useEffect, useImperativeHandle, useMemo, @@ -18,13 +20,13 @@ import { IDownstream, IRepresentation } from '../types'; import { compareShallowObject } from '../utils/comparators'; import useBooleanAccumulator from '../utils/useBooleanAccumulator'; import useComparableEffect from '../utils/useComparableEffect'; -import useLatest from '../utils/useLatest'; import { DownstreamContext, RepresentationContext, useRendererContext, } from './contexts'; import useColorTransferFunction from './modules/useColorTransferFunction'; +import useDataEvents from './modules/useDataEvents'; import useMapper from './modules/useMapper'; import useProp from './modules/useProp'; @@ -137,6 +139,11 @@ export default forwardRef(function GeometryRepresentation( getMapper().setLookupTable(getLookupTable()); }, [getMapper, getLookupTable]); + const getInputData = useCallback( + () => getMapper().getInputData(), + [getMapper] + ); + // --- actor --- // const actorProps = { @@ -167,15 +174,15 @@ export default forwardRef(function GeometryRepresentation( // --- events --- // - const onDataAvailable = useLatest(props.onDataAvailable); + const { dataChangedEvent, dataAvailableEvent } = + useDataEvents(props); + + // trigger data available event useEffect(() => { if (dataAvailable) { - // trigger onDataAvailable after making updates to the actor and mapper - onDataAvailable.current?.(); + dataAvailableEvent.current.dispatchEvent(getInputData()); } - // onDataAvailable is a ref - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dataAvailable]); + }, [dataAvailable, dataAvailableEvent, getInputData]); // --- // @@ -191,6 +198,7 @@ export default forwardRef(function GeometryRepresentation( const representation = useMemo( () => ({ dataChanged: () => { + dataChangedEvent.current.dispatchEvent(getInputData()); renderer.requestRender(); }, dataAvailable: () => { @@ -199,8 +207,17 @@ export default forwardRef(function GeometryRepresentation( }, getActor, getMapper, + onDataAvailable: (cb) => dataAvailableEvent.current.addEventListener(cb), + onDataChanged: (cb) => dataChangedEvent.current.addEventListener(cb), }), - [renderer, getActor, getMapper] + [ + renderer, + getActor, + getMapper, + getInputData, + dataAvailableEvent, + dataChangedEvent, + ] ); const downstream = useMemo( diff --git a/src/core/ShareDataSet.tsx b/src/core/ShareDataSet.tsx index f5d944f..2e1172d 100644 --- a/src/core/ShareDataSet.tsx +++ b/src/core/ShareDataSet.tsx @@ -31,6 +31,7 @@ import { useRepresentation, useShareDataSet, } from './contexts'; +import useDataEvents from './modules/useDataEvents'; const DATA_AVAILABLE_EVENT = 'dataAvailable'; const DATA_CHANGED_EVENT = 'dataChanged'; @@ -63,7 +64,7 @@ export function ShareDataSetRoot(props: PropsWithChildren) { }; const handler = (ev: Event) => { - if (!(ev instanceof CustomEvent)) return; + if (!(ev instanceof CustomEvent)) return; if (name === ev.detail?.name) { invoke(); } @@ -153,6 +154,10 @@ export function RegisterDataSet(props: RegisterDataSetProps) { share.unregister(id); }); + // --- events --- // + + const { dataChangedEvent, dataAvailableEvent } = useDataEvents({}); + // --- // const downstream = useMemo( @@ -171,14 +176,18 @@ export function RegisterDataSet(props: RegisterDataSetProps) { () => ({ dataChanged() { share.dispatchDataChanged(id); + dataChangedEvent.current.dispatchEvent(); }, dataAvailable() { share.dispatchDataAvailable(id); + dataAvailableEvent.current.dispatchEvent(); }, getActor: () => null, getMapper: () => null, + onDataAvailable: (cb) => share.onDataAvailable(id, cb), + onDataChanged: (cb) => share.onDataChanged(id, cb), }), - [id, share] + [id, share, dataAvailableEvent, dataChangedEvent] ); return ( diff --git a/src/core/SliceRepresentation.tsx b/src/core/SliceRepresentation.tsx index 968b4cf..ad0843e 100644 --- a/src/core/SliceRepresentation.tsx +++ b/src/core/SliceRepresentation.tsx @@ -1,3 +1,4 @@ +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; import AbstractImageMapper, { vtkAbstractImageMapper, } from '@kitware/vtk.js/Rendering/Core/AbstractImageMapper'; @@ -23,13 +24,13 @@ import { IDownstream, IRepresentation } from '../types'; import { compareShallowObject } from '../utils/comparators'; import useBooleanAccumulator from '../utils/useBooleanAccumulator'; import useComparableEffect from '../utils/useComparableEffect'; -import useLatest from '../utils/useLatest'; import { DownstreamContext, RepresentationContext, useRendererContext, } from './contexts'; import useColorTransferFunction from './modules/useColorTransferFunction'; +import useDataEvents from './modules/useDataEvents'; import useMapper from './modules/useMapper'; import useProp from './modules/useProp'; @@ -162,6 +163,11 @@ export default forwardRef(function SliceRepresentation( return getInternalMapper(); }, [mapperInstance, getInternalMapper]); + const getInputData = useCallback( + () => getMapper().getInputData(), + [getMapper] + ); + // --- actor --- // const actorProps = { @@ -254,15 +260,15 @@ export default forwardRef(function SliceRepresentation( // --- events --- // - const onDataAvailable = useLatest(props.onDataAvailable); + const { dataChangedEvent, dataAvailableEvent } = + useDataEvents(props); + + // trigger data available event useEffect(() => { if (dataAvailable) { - // trigger onDataAvailable after making updates to the actor and mapper - onDataAvailable.current?.(); + dataAvailableEvent.current.dispatchEvent(getInputData()); } - // onDataAvailable is a ref - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dataAvailable]); + }, [dataAvailable, dataAvailableEvent, getInputData]); // --- // @@ -278,6 +284,7 @@ export default forwardRef(function SliceRepresentation( const representation = useMemo( () => ({ dataChanged: () => { + dataChangedEvent.current.dispatchEvent(getInputData()); renderer.requestRender(); }, dataAvailable: (available = true) => { @@ -286,8 +293,17 @@ export default forwardRef(function SliceRepresentation( }, getActor, getMapper, + onDataAvailable: (cb) => dataAvailableEvent.current.addEventListener(cb), + onDataChanged: (cb) => dataChangedEvent.current.addEventListener(cb), }), - [renderer, getActor, getMapper] + [ + renderer, + getActor, + getMapper, + getInputData, + dataAvailableEvent, + dataChangedEvent, + ] ); const downstream = useMemo( diff --git a/src/core/VolumeRepresentation.tsx b/src/core/VolumeRepresentation.tsx index f5b7006..9623edb 100644 --- a/src/core/VolumeRepresentation.tsx +++ b/src/core/VolumeRepresentation.tsx @@ -1,4 +1,5 @@ import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; import vtkVolume, { IVolumeInitialValues, } from '@kitware/vtk.js/Rendering/Core/Volume'; @@ -20,13 +21,13 @@ import { IDownstream, IRepresentation } from '../types'; import { compareShallowObject } from '../utils/comparators'; import useBooleanAccumulator from '../utils/useBooleanAccumulator'; import useComparableEffect from '../utils/useComparableEffect'; -import useLatest from '../utils/useLatest'; import { DownstreamContext, RepresentationContext, useRendererContext, } from './contexts'; import useColorTransferFunction from './modules/useColorTransferFunction'; +import useDataEvents from './modules/useDataEvents'; import useDataRange from './modules/useDataRange'; import useMapper from './modules/useMapper'; import usePiecewiseFunction from './modules/usePiecewiseFunction'; @@ -76,7 +77,15 @@ export interface VolumeRepresentationProps extends PropsWithChildren { * - the actor is visible (unless explicitly marked as not visible) * - initial properties are set */ - onDataAvailable?: () => void; + onDataAvailable?: (obj?: vtkImageData) => void; + + /** + * Event callback for when data has changed. + * + * When called: + * - Mapper has input data + */ + onDataChanged?: (obj?: vtkImageData) => void; } const DefaultProps = { @@ -107,6 +116,11 @@ export default forwardRef(function VolumeRepresentation( return getInternalMapper(); }, [mapperInstance, getInternalMapper]); + const getInputData = useCallback( + () => getMapper().getInputData(), + [getMapper] + ); + // --- data range --- // const getDataArray = useCallback( @@ -173,15 +187,15 @@ export default forwardRef(function VolumeRepresentation( // --- events --- // - const onDataAvailable = useLatest(props.onDataAvailable); + const { dataChangedEvent, dataAvailableEvent } = + useDataEvents(props); + + // trigger data available event useEffect(() => { if (dataAvailable) { - // trigger onDataAvailable after making updates to the actor and mapper - onDataAvailable.current?.(); + dataAvailableEvent.current.dispatchEvent(getInputData()); } - // onDataAvailable is a ref - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dataAvailable]); + }, [dataAvailable, dataAvailableEvent, getInputData]); // --- // @@ -198,6 +212,7 @@ export default forwardRef(function VolumeRepresentation( () => ({ dataChanged: () => { updateDataRange(); + dataChangedEvent.current.dispatchEvent(getInputData()); renderer.requestRender(); }, dataAvailable: (available = true) => { @@ -206,11 +221,18 @@ export default forwardRef(function VolumeRepresentation( }, getActor, getMapper, - getData: () => { - return getMapper().getInputData(); - }, + onDataAvailable: (cb) => dataAvailableEvent.current.addEventListener(cb), + onDataChanged: (cb) => dataChangedEvent.current.addEventListener(cb), }), - [renderer, updateDataRange, getActor, getMapper] + [ + renderer, + updateDataRange, + getActor, + getMapper, + getInputData, + dataAvailableEvent, + dataChangedEvent, + ] ); const downstream = useMemo( diff --git a/src/core/modules/useDataEvents.ts b/src/core/modules/useDataEvents.ts new file mode 100644 index 0000000..0fa24a9 --- /dev/null +++ b/src/core/modules/useDataEvents.ts @@ -0,0 +1,26 @@ +import { useEffect, useRef } from 'react'; +import { createEvent } from '../../utils/createEvent'; + +interface UseDataEventsProps { + onDataAvailable?: (obj?: T) => void; + onDataChanged?: (obj?: T) => void; +} + +export default function useDataEvents(props: UseDataEventsProps) { + const dataChangedEvent = useRef(createEvent()); + const dataAvailableEvent = useRef(createEvent()); + + const { onDataAvailable } = props; + useEffect(() => { + if (!onDataAvailable) return; + return dataAvailableEvent.current.addEventListener(onDataAvailable); + }, [onDataAvailable, dataAvailableEvent]); + + const { onDataChanged } = props; + useEffect(() => { + if (!onDataChanged) return; + return dataChangedEvent.current.addEventListener(onDataChanged); + }, [onDataChanged, dataChangedEvent]); + + return { dataChangedEvent, dataAvailableEvent }; +} diff --git a/src/types.ts b/src/types.ts index 74d427b..16e3c13 100644 --- a/src/types.ts +++ b/src/types.ts @@ -74,6 +74,8 @@ export interface IRepresentation { dataChanged(): void; getActor(): vtkProp | null; getMapper(): vtkAbstractMapper | null; + onDataAvailable(cb: (obj?: any) => void): () => void; + onDataChanged(cb: (obj?: any) => void): () => void; } // There is no sufficient type that overlaps classes like diff --git a/src/utils/createEvent.ts b/src/utils/createEvent.ts new file mode 100644 index 0000000..79e10d5 --- /dev/null +++ b/src/utils/createEvent.ts @@ -0,0 +1,30 @@ +/** + * Creates an event for listening. + */ +export function createEvent() { + type Callback = (ev?: T) => void; + + const callbacks: Callback[] = []; + + const removeEventListener = (cb: () => void) => { + const idx = callbacks.indexOf(cb); + if (idx > -1) { + callbacks.splice(idx, 1); + } + }; + + const addEventListener = (cb: (ev?: T) => void) => { + callbacks.push(cb); + return () => { + removeEventListener(cb); + }; + }; + + const dispatchEvent = (ev?: T) => { + callbacks.forEach((cb) => { + cb(ev); + }); + }; + + return { addEventListener, removeEventListener, dispatchEvent }; +} From 71b04b8a3ca8612d8f0ab3876b080f1e1442df99 Mon Sep 17 00:00:00 2001 From: Forrest Date: Tue, 16 Jan 2024 12:57:30 -0500 Subject: [PATCH 3/4] feat(VolumeController): add impl --- src/core/VolumeController.tsx | 122 ++++++++++++++++++++++++++++++++++ src/global.d.ts | 5 ++ src/index.ts | 1 + 3 files changed, 128 insertions(+) create mode 100644 src/core/VolumeController.tsx diff --git a/src/core/VolumeController.tsx b/src/core/VolumeController.tsx new file mode 100644 index 0000000..4a251a7 --- /dev/null +++ b/src/core/VolumeController.tsx @@ -0,0 +1,122 @@ +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import vtkVolumeController from '@kitware/vtk.js/Interaction/UI/VolumeController'; +import { useCallback, useContext, useEffect, useRef } from 'react'; +import { Contexts } from '..'; +import deletionRegistry from '../utils/DeletionRegistry'; +import useGetterRef from '../utils/useGetterRef'; +import useUnmount from '../utils/useUnmount'; +import { useRepresentation } from './contexts'; + +export interface VolumeControllerProps { + /** + * The size of the widget (width, height) + */ + size?: [number, number]; + + /** + * Flag for rescaling the colormap. + */ + rescaleColorMap?: boolean; +} + +const DefaultProps = { + size: [400, 150], + rescaleColorMap: true, +}; + +export default function VolumeController(props: VolumeControllerProps) { + const containerRef = useRef(null); + const [controllerRef, getController] = useGetterRef(() => { + const ctrlr = vtkVolumeController.newInstance(); + deletionRegistry.markForDeletion(() => ctrlr.delete()); + return ctrlr; + }); + + const view = useContext(Contexts.ViewContext); + if (!view) throw new Error('Need view context'); + + const volumeRep = useRepresentation(); + + const updateWidget = useCallback(() => { + const volume = volumeRep.getMapper()?.getInputData() as + | vtkImageData + | undefined; + if (!volume) return; + + const widget = getController().getWidget(); + const dataArray = + volume.getPointData().getScalars() || + volume.getPointData().getArrays()[0]; + widget.setDataArray(dataArray.getData()); + }, [volumeRep, getController]); + + // --- set actor --- // + + const updateActor = useCallback(() => { + // set the actor prior to setting props, since + // setRescaleColorMap depends on the actor + getController().setActor(volumeRep.getActor()); + }, [getController, volumeRep]); + + useEffect(() => { + return volumeRep.onDataAvailable(updateActor); + }, [volumeRep, updateActor]); + + // --- prop handling --- // + + const { size = DefaultProps.size } = props; + + useEffect(() => { + if (!size) return; + getController().setSize(size[0], size[1]); + }, [size, getController]); + + const { rescaleColorMap = DefaultProps.rescaleColorMap } = props; + + useEffect(() => { + if (rescaleColorMap === undefined || !getController().getActor()) return; + + getController().setRescaleColorMap(rescaleColorMap); + }, [rescaleColorMap, getController]); + + // --- initialization --- // + + const init = useCallback( + (volume?: vtkImageData) => { + const container = containerRef.current; + const rw = view.getRenderWindow()?.get(); + + if (!volume || !rw) return; + + const controller = getController(); + + controller.setContainer(container); + controller.setupContent(rw, volumeRep.getActor(), false); + controller.render(); + view.requestRender(); + + const sub = volume.onModified(() => { + updateWidget(); + }); + + return () => { + controller.setContainer(null); + sub.unsubscribe(); + }; + }, + [view, volumeRep, updateWidget, getController] + ); + + useEffect(() => { + return volumeRep.onDataAvailable(init); + }, [volumeRep, init]); + + useUnmount(() => { + if (controllerRef.current) { + deletionRegistry.markForDeletion(controllerRef.current); + controllerRef.current = null; + } + }); + + return
; +} diff --git a/src/global.d.ts b/src/global.d.ts index 54527d8..56aed86 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -485,3 +485,8 @@ declare module '@kitware/vtk.js/Rendering/OpenGL/HardwareSelector' { export default vtkOpenGLHardwareSelector; } + +declare module '@kitware/vtk.js/Interaction/UI/VolumeController' { + const VolumeController: any; + export default VolumeController; +} diff --git a/src/index.ts b/src/index.ts index d305c46..46ccee6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,4 +44,5 @@ export { default as SliceRepresentation } from './core/SliceRepresentation'; export type { SliceRepresentationProps } from './core/SliceRepresentation'; export { default as View } from './core/View'; export type { ViewProps } from './core/View'; +export { default as VolumeController } from './core/VolumeController'; export { default as VolumeRepresentation } from './core/VolumeRepresentation'; From f03d81a077fa0741720daa5f0577f51ab0958d29 Mon Sep 17 00:00:00 2001 From: Forrest Date: Tue, 16 Jan 2024 13:00:47 -0500 Subject: [PATCH 4/4] docs(usage): add demos for volume representation --- usage/src/App.jsx | 7 +- usage/src/Volume/DynamicUpdate.jsx | 108 ++++++------ usage/src/Volume/SliceRendering.jsx | 244 ++++++++++++++------------- usage/src/Volume/VolumeRendering.jsx | 15 +- 4 files changed, 190 insertions(+), 184 deletions(-) diff --git a/usage/src/App.jsx b/usage/src/App.jsx index c0235c5..f00ed69 100644 --- a/usage/src/App.jsx +++ b/usage/src/App.jsx @@ -21,14 +21,13 @@ const demos = new Map([ // ['Geometry/Glyph', lazy(() => import('./Geometry/Glyph'))], ['Geometry/CutterExample', lazy(() => import('./Geometry/CutterExample'))], ['Geometry/TubeExample', lazy(() => import('./Geometry/TubeExample'))], - // ['Volume/SliceRendering', lazy(() => import('./Volume/SliceRendering'))], - // ['Volume/ImageSeriesRendering', lazy(() => import('./Volume/ImageSeriesRendering'))], + ['Volume/SliceRendering', lazy(() => import('./Volume/SliceRendering'))], // [ // 'Volume/SyntheticVolumeRendering', // lazy(() => import('./Volume/SyntheticVolumeRendering')), // ], - // ['Volume/VolumeRendering', lazy(() => import('./Volume/VolumeRendering'))], - // ['Volume/DynamicUpdate', lazy(() => import('./Volume/DynamicUpdate'))], + ['Volume/VolumeRendering', lazy(() => import('./Volume/VolumeRendering'))], + ['Volume/DynamicUpdate', lazy(() => import('./Volume/DynamicUpdate'))], [ 'Volume/ImageSeriesRendering', lazy(() => import('./Volume/ImageSeriesRendering')), diff --git a/usage/src/Volume/DynamicUpdate.jsx b/usage/src/Volume/DynamicUpdate.jsx index 7d109b6..12870cd 100644 --- a/usage/src/Volume/DynamicUpdate.jsx +++ b/usage/src/Volume/DynamicUpdate.jsx @@ -1,15 +1,17 @@ -import React, { useState, useContext } from 'react'; +import { useContext, useState } from 'react'; import { Contexts, - View, - VolumeRepresentation, - SliceRepresentation, - VolumeController, - ShareDataSet, + DataArray, ImageData, PointData, - DataArray, + RegisterDataSet, + ShareDataSetRoot, + SliceRepresentation, + UseDataSet, + View, + VolumeController, + VolumeRepresentation, } from 'react-vtk-js'; function generateRandomVolumeField(iMax, jMax, kMax) { @@ -70,57 +72,61 @@ function Slider(props) { ); } -function Example(props) { +function Example() { const [fieldIdx, setFieldIdx] = useState(0); const colorWindow = fieldIdx ? 10 : 1; const colorLevel = fieldIdx ? 5 : 0.5; return ( -
- - - - - - - - - - - - - -
- - - - + + + + + + + + +
+ + + + + + + +
+ + + + + +
-
+ ); } diff --git a/usage/src/Volume/SliceRendering.jsx b/usage/src/Volume/SliceRendering.jsx index b0c3d75..3f33e28 100644 --- a/usage/src/Volume/SliceRendering.jsx +++ b/usage/src/Volume/SliceRendering.jsx @@ -1,12 +1,14 @@ -import React, { useState, useContext } from 'react'; import vtkColorMaps from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction/ColorMaps.js'; +import { useContext, useState } from 'react'; import { - View, - ShareDataSet, - SliceRepresentation, - Reader, Contexts, + Reader, + RegisterDataSet, + ShareDataSetRoot, + SliceRepresentation, + UseDataSet, + View, VolumeController, VolumeRepresentation, } from 'react-vtk-js'; @@ -66,8 +68,10 @@ function DropDown(props) { ...props.style, }} > - {props.options.map((opt) => ( - + {props.options.map((opt, idx) => ( + ))} ); @@ -106,7 +110,7 @@ function CheckBox(props) { ); } -function Example(props) { +function Example() { const [iSlice, setISlice] = useState(128); const [jSlice, setJSlice] = useState(128); const [kSlice, setKSlice] = useState(47); @@ -116,120 +120,122 @@ function Example(props) { const [useLookupTableScalarRange, setUseLookupTableScalarRange] = useState(false); return ( -
-
- - - - - - - - - - - - - - - + + + +
+
+ - - - + + + + + + + + + + + + + + + + +
+
+ - - - -
-
- - -
- -
- -
-
+ +
+ +
+ +
+ +
-
+ ); } diff --git a/usage/src/Volume/VolumeRendering.jsx b/usage/src/Volume/VolumeRendering.jsx index 913a557..68c87d7 100644 --- a/usage/src/Volume/VolumeRendering.jsx +++ b/usage/src/Volume/VolumeRendering.jsx @@ -1,24 +1,19 @@ -import React from 'react'; - +import vtkXMLImageDataReader from '@kitware/vtk.js/IO/XML/XMLImageDataReader'; import { + Reader, View, - VolumeRepresentation, VolumeController, - Reader, + VolumeRepresentation, } from 'react-vtk-js'; -function Example(props) { - const array = []; - while (array.length < 1000) { - array.push(Math.random()); - } +function Example() { return (