Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Widget component factory for tools #420

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions src/components/tools/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { AnnotationToolStore } from '@/src/store/tools/useAnnotationTool';
import { Maybe } from '@/src/types';
import { AnnotationTool } from '@/src/types/annotation-tool';
import { vtkAnnotationToolWidget } from '@/src/vtk/ToolWidgetUtils/utils';
import vtkAbstractWidgetFactory from '@kitware/vtk.js/Widgets/Core/AbstractWidgetFactory';
import vtkWidgetState from '@kitware/vtk.js/Widgets/Core/WidgetState';
import { VNode } from 'vue';

export interface WidgetComponentMeta<
ToolID extends string,
ToolStore extends AnnotationToolStore<ToolID>,
WidgetState extends vtkWidgetState,
WidgetFactory extends vtkAbstractWidgetFactory,
ViewWidget extends vtkAnnotationToolWidget,
SyncedState,
InitState
> {
/**
* The name of the component.
*/
name: string;

/**
* The associated tool store.
*/
useToolStore: () => ToolStore;

/**
* Construct a new standalone widget state.
* Used by the placing widget component.
*/
createStandaloneState: () => WidgetState;

/**
* Construct a new store-backed state.
*/
createStoreBackedState: (id: ToolID, store: ToolStore) => WidgetState;

/**
* Construct a widget factory.
*/
createWidgetFactory: (widgetState: WidgetState) => WidgetFactory;

/**
* A composable that syncs the widget factory's state to a reactive object.
*/
useSyncedState: (widgetFactory: WidgetFactory) => SyncedState;
Copy link
Collaborator

@PaulHax PaulHax Sep 15, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is needed because PlacingWidget does not pull from a store and we want a normalized representation of state for Placing and NonPlacing components? We do pass the tool to store backed NonPlacingComponents, so passing duplicate info to NonPlacing components. Maybe toss ID into SyncedState, and only NonPlacingCompoents get access, then can drop tool prop.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are certainly passing some duplicate info, but the synced state captures some ephemeral widget state that the store might not capture. For example, the polygon tool currently stores a movePoint. I consider that to be ephemeral state that doesn't need to be stored, only propagated from the widget itself.


/**
* Resets the placing widget.
*/
resetPlacingWidget: (widget: ViewWidget) => void;

/**
* Widget has been placed, so construct the init state object.
*
* The init state object is used to construct the actual store tool.
*/
constructInitState: (state: SyncedState) => InitState;

/**
* An optional render function
*
* @param viewId the view ID
* @param syncedState the synced state from the widget
* @param labelProps the label props associated with the tool
* @param tool an optional tool. Not available for the placing widget.
*/
render?: (
viewId: string,
syncedState: SyncedState,
labelProps: Maybe<ToolStore['labels'][string]>,
tool?: AnnotationTool<ToolID>
) => VNode;
}
131 changes: 131 additions & 0 deletions src/components/tools/createAnnotationToolComponent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { LPSAxisDir } from '@/src/types/lps';
import vtkWidgetManager from '@kitware/vtk.js/Widgets/Core/WidgetManager';
import { computed, defineComponent, h, toRefs } from 'vue';
import type { Component, ComputedRef, PropType } from 'vue';
import AnnotationContextMenu from '@/src/components/tools/AnnotationContextMenu.vue';
import { useToolStore as useToolMetaStore } from '@/src/store/tools';
import { AnnotationToolStore } from '@/src/store/tools/useAnnotationTool';
import { Tools } from '@/src/store/tools/types';
import { useCurrentImage } from '@/src/composables/useCurrentImage';
import { getLPSAxisFromDir } from '@/src/utils/lps';
import { useCurrentFrameOfReference } from '@/src/composables/useCurrentFrameOfReference';
import {
useContextMenu,
useCurrentTools,
} from '@/src/composables/annotationTool';
import { Maybe } from '@/src/types';
import { ContextMenuEvent } from '@/src/types/annotation-tool';

export interface AnnotationToolComponentMeta<
ToolID extends string,
ToolStore extends AnnotationToolStore<ToolID>
> {
type: Tools;
name: string;
useToolStore: () => ToolStore;
Widget2DComponent: Component;
PlacingWidget2DComponent: Component;
}

export function createAnnotationToolComponent<
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Vue component factories?? Cool! (like React =) For my edifcation, why a factory instead of passing everything as props to some generic compoenent?

The other naive approach would have been to just start shoving all the logic into Composables. Probably would have ended up with one big useWidget2D and RulerWidget2D/PolygonWidget2D would be shells. Opprotunity for overrides/special-casing per tool.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After trying a component factory, I think we should use a big composable/some smaller composables. Like you said, we can have per-tool overrides, and I think constraining tools to an arbitrary interface is counter-productive. Plus, the use of render functions is not great. I'll update accordingly and let you know.

ToolID extends string,
ToolStore extends AnnotationToolStore<ToolID>
>(meta: AnnotationToolComponentMeta<ToolID, ToolStore>) {
return defineComponent({
name: meta.name,
props: {
viewId: {
type: String,
required: true,
},
currentSlice: {
type: Number,
required: true,
},
viewDirection: {
type: String as PropType<LPSAxisDir>,
required: true,
},
widgetManager: {
type: Object as PropType<vtkWidgetManager>,
required: true,
},
},
setup(props) {
type LabelProps = ToolStore['labels'][string];

const { viewId, widgetManager, viewDirection, currentSlice } =
toRefs(props);
const toolMetaStore = useToolMetaStore();
const toolStore = meta.useToolStore();
const activeLabel = computed(() => toolStore.activeLabel);

const { currentImageID } = useCurrentImage();
const isToolActive = computed(
() => toolMetaStore.currentTool === meta.type
);
const viewAxis = computed(() => getLPSAxisFromDir(viewDirection.value));

const currentFrameOfReference = useCurrentFrameOfReference(
viewDirection,
currentSlice
);

const onToolPlaced = (initState: object) => {
if (!currentImageID.value) return;
toolStore.addTool({
imageID: currentImageID.value,
frameOfReference: currentFrameOfReference.value,
slice: currentSlice.value,
label: activeLabel.value,
...initState,
});
};

// --- right-click menu --- //

const { contextMenu, openContextMenu } = useContextMenu();

// --- template data --- //

const currentTools = useCurrentTools(toolStore, viewAxis);

const activeLabelProps: ComputedRef<Maybe<LabelProps>> = computed(() => {
return activeLabel.value
? (toolStore.labels[activeLabel.value] as LabelProps)
: null;
});

const render = () =>
h('div', { class: 'overlay-no-events' }, [
h('svg', { class: 'overlay-no-events' }, [
isToolActive.value
? h(meta.PlacingWidget2DComponent, {
currentSlice: currentSlice.value,
viewDirection: viewDirection.value,
widgetManager: widgetManager.value,
viewId: viewId.value,
labelProps: activeLabelProps.value,
onPlaced: onToolPlaced,
})
: null,
...currentTools.value.map((tool) =>
h(meta.Widget2DComponent, {
key: tool.id,
toolId: tool.id,
currentSlice: currentSlice.value,
viewId: viewId.value,
viewDirection: viewDirection.value,
widgetManager: widgetManager.value,
onContextmenu: (event: ContextMenuEvent) =>
openContextMenu(tool.id, event),
})
),
]),
h(AnnotationContextMenu<ToolID>, { ref: contextMenu, toolStore }),
]);

return render;
},
});
}
143 changes: 143 additions & 0 deletions src/components/tools/createPlacingWidget2DComponent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { WidgetComponentMeta } from '@/src/components/tools/common';
import { onVTKEvent } from '@/src/composables/onVTKEvent';
import { useCurrentImage } from '@/src/composables/useCurrentImage';
import { useViewWidget } from '@/src/composables/useViewWidget';
import { AnnotationToolStore } from '@/src/store/tools/useAnnotationTool';
import { Maybe } from '@/src/types';
import { LPSAxisDir } from '@/src/types/lps';
import { updatePlaneManipulatorFor2DView } from '@/src/utils/manipulators';
import { vtkAnnotationToolWidget } from '@/src/vtk/ToolWidgetUtils/utils';
import vtkAbstractWidgetFactory from '@kitware/vtk.js/Widgets/Core/AbstractWidgetFactory';
import vtkWidgetManager from '@kitware/vtk.js/Widgets/Core/WidgetManager';
import vtkWidgetState from '@kitware/vtk.js/Widgets/Core/WidgetState';
import vtkPlaneManipulator from '@kitware/vtk.js/Widgets/Manipulators/PlaneManipulator';
import {
PropType,
Ref,
defineComponent,
onMounted,
onUnmounted,
toRefs,
watch,
watchEffect,
} from 'vue';

export function createPlacingWidget2DComponent<
ToolID extends string,
ToolStore extends AnnotationToolStore<ToolID>,
WidgetState extends vtkWidgetState,
WidgetFactory extends vtkAbstractWidgetFactory,
ViewWidget extends vtkAnnotationToolWidget,
SyncedState,
InitState
>(
meta: WidgetComponentMeta<
ToolID,
ToolStore,
WidgetState,
WidgetFactory,
ViewWidget,
SyncedState,
InitState
>
) {
type LabelProps = ToolStore['labels'][string];
return defineComponent({
name: meta.name,
emits: ['placed'],
props: {
widgetManager: {
type: Object as PropType<vtkWidgetManager>,
required: true,
},
viewId: {
type: String,
required: true,
},
viewDirection: {
type: String as PropType<LPSAxisDir>,
required: true,
},
currentSlice: {
type: Number,
required: true,
},
labelProps: {
type: Object as PropType<LabelProps>,
required: false,
},
},
setup(props, { emit }) {
const { viewId, labelProps, widgetManager, viewDirection, currentSlice } =
toRefs(props);

const { currentImageID, currentImageMetadata } = useCurrentImage();

const widgetState = meta.createStandaloneState();
const widgetFactory = meta.createWidgetFactory(widgetState);

const syncedState = meta.useSyncedState(widgetFactory);
const widget = useViewWidget<ViewWidget>(widgetFactory, widgetManager);

onMounted(() => {
meta.resetPlacingWidget(widget.value!);
});

onUnmounted(() => {
widgetFactory.delete();
});

// --- reset on slice/image changes --- //

watch([currentSlice, currentImageID, widget], () => {
if (widget.value) {
meta.resetPlacingWidget(widget.value);
}
});

// --- placed event --- //

onVTKEvent(
widget as Ref<Maybe<vtkAnnotationToolWidget>>,
'onPlacedEvent',
() => {
const initState = meta.constructInitState(syncedState);
emit('placed', initState);
meta.resetPlacingWidget(widget.value!);
}
);

// --- manipulator --- //

const manipulator = vtkPlaneManipulator.newInstance();

onMounted(() => {
widget.value?.setManipulator(manipulator);
});

watchEffect(() => {
updatePlaneManipulatorFor2DView(
manipulator,
viewDirection.value,
currentSlice.value,
currentImageMetadata.value
);
});

// --- visibility --- //

onMounted(() => {
if (!widget.value) {
return;
}
// hide handle visibility, but not picking visibility
widget.value.setHandleVisibility(false);
widgetManager.value.renderWidgets();
});

// --- //

return () => meta.render?.(viewId.value, syncedState, labelProps.value);
},
});
}
Loading