diff --git a/src/components/tools/polygon/PolygonSVG2D.vue b/src/components/tools/polygon/PolygonSVG2D.vue index 5d805414..fe50614a 100644 --- a/src/components/tools/polygon/PolygonSVG2D.vue +++ b/src/components/tools/polygon/PolygonSVG2D.vue @@ -9,6 +9,7 @@ :stroke-width="strokeWidth" fill="transparent" :r="radius" + :visibility="index === 0 ? firstHandleVisibility : handleVisibility" /> { + return points.value.length > 0 && placing.value && finishable.value; + }); + + const handleVisibility = computed(() => { + return showHandles.value ? 'visible' : 'hidden'; + }); + const firstHandleVisibility = computed(() => { + if (finishPossible.value) { + return 'visible'; + } + return handleVisibility.value; + }); + type SVGPoint = { point: Vector2; radius: number }; const handlePoints = ref>([]); const linePoints = ref(''); @@ -74,7 +102,7 @@ export default defineComponent({ }); // Indicate finishable - if (finishable.value && placing.value) { + if (finishPossible.value) { svgPoints[0].radius = FINISHABLE_POINT_RADIUS; } @@ -117,6 +145,8 @@ export default defineComponent({ devicePixelRatio, handlePoints, linePoints, + firstHandleVisibility, + handleVisibility, }; }, }); diff --git a/src/components/tools/polygon/PolygonWidget2D.vue b/src/components/tools/polygon/PolygonWidget2D.vue index 8bb82c6a..6c151f6e 100644 --- a/src/components/tools/polygon/PolygonWidget2D.vue +++ b/src/components/tools/polygon/PolygonWidget2D.vue @@ -9,6 +9,7 @@ import { watchEffect, inject, onUnmounted, + ref, } from 'vue'; import vtkPlaneManipulator from '@kitware/vtk.js/Widgets/Manipulators/PlaneManipulator'; import { useImage } from '@/src/composables/useCurrentImage'; @@ -16,10 +17,10 @@ import { updatePlaneManipulatorFor2DView } from '@/src/utils/manipulators'; import { LPSAxisDir } from '@/src/types/lps'; import { onVTKEvent } from '@/src/composables/onVTKEvent'; import { - useHoverEvent, useRightClickContextMenu, useWidgetVisibility, } from '@/src/composables/annotationTool'; +import { getCSSCoordinatesFromEvent } from '@/src/utils/vtk-helpers'; import { usePolygonStore as useStore } from '@/src/store/tools/polygons'; import vtkWidgetFactory, { vtkPolygonViewWidget as WidgetView, @@ -92,7 +93,27 @@ export default defineComponent({ emit('placed'); }); - useHoverEvent(emit, widget); + const lastHoverEventData = ref(null); + onVTKEvent(widget, 'onHoverEvent', (eventData: any) => { + lastHoverEventData.value = eventData; + }); + const dragging = ref(false); + onVTKEvent(widget, 'onDraggingEvent', (eventData: any) => { + dragging.value = eventData.dragging; + }); + const showHandles = computed(() => { + return lastHoverEventData.value?.hovering && !dragging.value; + }); + watchEffect(() => { + if (!lastHoverEventData.value) return; + const displayXY = getCSSCoordinatesFromEvent(lastHoverEventData.value); + if (displayXY) { + emit('widgetHover', { + displayXY, + hovering: lastHoverEventData.value?.hovering && !dragging.value, + }); + } + }); // --- right click handling --- // @@ -134,6 +155,7 @@ export default defineComponent({ slice, tool, editState, + showHandles, }; }, }); @@ -148,5 +170,6 @@ export default defineComponent({ :move-point="editState.movePoint" :placing="tool.placing" :finishable="editState.finishable" + :show-handles="showHandles" /> diff --git a/src/utils/frameOfReference.ts b/src/utils/frameOfReference.ts index 9636fd4d..016e6999 100644 --- a/src/utils/frameOfReference.ts +++ b/src/utils/frameOfReference.ts @@ -76,3 +76,15 @@ export function frameOfReferenceToImageSliceAndAxis( return { axis, slice }; } + +export function getSmallestSpacing( + frame: FrameOfReference, + metadata: ImageMetadata +): number { + const sliceAxis = frameOfReferenceToImageSliceAndAxis(frame, metadata); + if (!sliceAxis) return Math.min(...metadata.spacing); // off orthogonal + const axisIndex = metadata.lpsOrientation[sliceAxis.axis]; + const spacing = [...metadata.spacing]; + spacing.splice(axisIndex, 1); + return Math.min(...spacing); +} diff --git a/src/vtk/PolygonWidget/behavior.ts b/src/vtk/PolygonWidget/behavior.ts index 6d4c80f0..b2f63b13 100644 --- a/src/vtk/PolygonWidget/behavior.ts +++ b/src/vtk/PolygonWidget/behavior.ts @@ -22,17 +22,25 @@ const DOUBLE_CLICK_SLIP_DISTANCE_MAX_SQUARED = export default function widgetBehavior(publicAPI: any, model: any) { model.classHierarchy.push('vtkPolygonWidgetBehavior'); - model._isDragging = false; + + const setDragging = (isDragging: boolean) => { + model._dragging = isDragging; + publicAPI.invokeDraggingEvent({ + dragging: isDragging, + }); + }; // overUnselectedHandle is true if mouse is over handle that was created before a mouse move event. - // If creating new handle and immediately dragging, - // widgetManager.getSelections() still points to the last actor - // after the mouse button is released. In this widgets case the last actor is part of the LineGlyphRepresentation. - // So overUnselectedHandle tracks if the mouse is over the new handle so we + // That happens if creating new handle and immediately dragging. + // Then widgetManager.getSelections() still points to the last actor + // after the mouse button is released. In this widgets case, the last actor is part of the LineGlyphRepresentation. + // overUnselectedHandle tracks if the mouse is over the new handle so we // don't create a another handle when clicking after without mouse move. // A mouse move event sets overUnselectedHandle to false as we can then rely on widgetManager.getSelections(). let overUnselectedHandle = false; + let freeHanding = false; + // Check if mouse is over line segment between handles const checkOverSegment = () => { // overSegment guards against clicking anywhere in view @@ -50,6 +58,7 @@ export default function widgetBehavior(publicAPI: any, model: any) { macro.event(publicAPI, model, 'RightClickEvent'); macro.event(publicAPI, model, 'PlacedEvent'); macro.event(publicAPI, model, 'HoverEvent'); + macro.event(publicAPI, model, 'DraggingEvent'); publicAPI.resetInteractions = () => { model._interactor.cancelAnimation(publicAPI, true); @@ -113,13 +122,15 @@ export default function widgetBehavior(publicAPI: any, model: any) { const getWorldCoords = computeWorldCoords(model); + // returns macro.EVENT_ABORT if dragging handle or finishing + // to indicate event should be consumed. function updateActiveStateHandle(callData: any) { const worldCoords = getWorldCoords(callData); if ( worldCoords?.length && (model.activeState === model.widgetState.getMoveHandle() || - model._isDragging) + model._dragging) ) { model.activeState.setOrigin(worldCoords); @@ -138,6 +149,12 @@ export default function widgetBehavior(publicAPI: any, model: any) { return macro.VOID; } + function addHandle() { + const moveHandle = model.widgetState.getMoveHandle(); + const newHandle = model.widgetState.addHandle(); + newHandle.setOrigin(moveHandle.getOrigin()); + } + // -------------------------------------------------------------------------- // Left press: Select handle to drag / Add new handle // -------------------------------------------------------------------------- @@ -145,12 +162,6 @@ export default function widgetBehavior(publicAPI: any, model: any) { publicAPI.handleLeftButtonPress = (event: vtkMouseEvent) => { const activeWidget = model._widgetManager.getActiveWidget(); - // turns off hover while dragging - publicAPI.invokeHoverEvent({ - ...event, - hovering: false, - }); - if ( !model.manipulator || ignoreKey(event) || @@ -164,12 +175,13 @@ export default function widgetBehavior(publicAPI: any, model: any) { return macro.VOID; } + // Drop point? const manipulator = model.activeState?.getManipulator?.() ?? model.manipulator; if (model.widgetState.getPlacing() && manipulator) { // Dropping first point? if (model.widgetState.getHandles().length === 0) { - // For updateActiveStateHandle + // update variables used by updateActiveStateHandle model.activeState = model.widgetState.getMoveHandle(); model._widgetManager.grabFocus(publicAPI); } @@ -181,20 +193,16 @@ export default function widgetBehavior(publicAPI: any, model: any) { return macro.EVENT_ABORT; } - // Add handle - const moveHandle = model.widgetState.getMoveHandle(); - const newHandle = model.widgetState.addHandle(); - newHandle.setOrigin(moveHandle.getOrigin()); - + addHandle(); publicAPI.invokeStartInteractionEvent(); + freeHanding = true; return macro.EVENT_ABORT; } if (model.activeState?.getActive() && model.pickable && model.dragable) { - model._isDragging = true; + setDragging(true); model._apiSpecificRenderWindow.setCursor('grabbing'); model._interactor.requestAnimation(publicAPI); - publicAPI.invokeStartInteractionEvent(); return macro.EVENT_ABORT; } @@ -211,16 +219,18 @@ export default function widgetBehavior(publicAPI: any, model: any) { model.pickable && model.dragable && model.activeState && - !ignoreKey(event) + !ignoreKey(event) && + updateActiveStateHandle(event) === macro.EVENT_ABORT // side effect! ) { - if (updateActiveStateHandle(event) === macro.EVENT_ABORT) { - return macro.EVENT_ABORT; + if (freeHanding) { + addHandle(); } + return macro.EVENT_ABORT; // consume event } // widgetManager.getSelections() updates on mouse move and not animating. // (Widget triggers animation when dragging.) - // So we can rely on getSelections() to be up to date now. + // So we can rely on getSelections() to be up to date now overUnselectedHandle = false; if (model.hasFocus) { @@ -239,9 +249,33 @@ export default function widgetBehavior(publicAPI: any, model: any) { // Left release: Finish drag // -------------------------------------------------------------------------- - // Detect double click by comparing these values. + // Detect double click by diffing these. let lastReleaseTime = 0; let lastReleasePosition: Vector3 | undefined; + function isDoubleClick(event: vtkMouseEvent) { + const currentTime = Date.now(); + const currentDisplayPos = [ + event.position.x, + event.position.y, + event.position.z, + ] as Vector3; + const elapsed = currentTime - lastReleaseTime; + + const distance = lastReleasePosition + ? distance2BetweenPoints( + [event.position.x, event.position.y, event.position.z], + lastReleasePosition + ) + : Number.POSITIVE_INFINITY; + + const doubleClicked = + elapsed < DOUBLE_CLICK_TIMEOUT && + distance < DOUBLE_CLICK_SLIP_DISTANCE_MAX_SQUARED; + + lastReleaseTime = currentTime; + lastReleasePosition = currentDisplayPos; + return doubleClicked; + } publicAPI.handleLeftButtonRelease = (event: vtkMouseEvent) => { if ( @@ -252,44 +286,29 @@ export default function widgetBehavior(publicAPI: any, model: any) { return macro.VOID; } - if (model._isDragging) { + freeHanding = false; + + if (model._dragging) { model._apiSpecificRenderWindow.setCursor('pointer'); model._interactor.cancelAnimation(publicAPI); - model._isDragging = false; + setDragging(false); model._widgetManager.enablePicking(); // So a following left click without moving the mouse can immediately grab the handle, // we don't call model.widgetState.deactivate() here. - publicAPI.invokeEndInteractionEvent(); return macro.EVENT_ABORT; } - // If not placing, (and not dragging) don't consume event + // If not placing (and not dragging) don't consume event // so camera control widgets can react. if (!model.widgetState.getPlacing()) { return macro.VOID; } - // Double click? Then finish. - const currentDisplayPos = [ - event.position.x, - event.position.y, - event.position.z, - ] as Vector3; - - const distance = lastReleasePosition - ? distance2BetweenPoints(currentDisplayPos, lastReleasePosition) - : Number.POSITIVE_INFINITY; - lastReleasePosition = currentDisplayPos; - - const currentTime = Date.now(); - const elapsed = currentTime - lastReleaseTime; - - const distanceThreshold = - DOUBLE_CLICK_SLIP_DISTANCE_MAX_SQUARED * - model._apiSpecificRenderWindow.getComputedDevicePixelRatio(); - - if (elapsed < DOUBLE_CLICK_TIMEOUT && distance < distanceThreshold) { + if (model.widgetState.getFinishable()) { + finishPlacing(); + } else if (isDoubleClick(event)) { + // try to finish placing const handles = model.widgetState.getHandles(); // Need 3 handles to finish. Double click created 2 handles, 1 extra. if (handles.length >= 4) { @@ -297,12 +316,12 @@ export default function widgetBehavior(publicAPI: any, model: any) { finishPlacing(); } } - lastReleaseTime = currentTime; if ( (model.hasFocus && !model.activeState) || (model.activeState && !model.activeState.getActive()) ) { + // update if mouse hovered over handle/activeState for next onDown model._widgetManager.enablePicking(); model._interactor.render(); } diff --git a/src/vtk/PolygonWidget/decimate.ts b/src/vtk/PolygonWidget/decimate.ts new file mode 100644 index 00000000..8e78dc05 --- /dev/null +++ b/src/vtk/PolygonWidget/decimate.ts @@ -0,0 +1,132 @@ +/* eslint-disable no-continue */ + +/* +File modified from https://github.com/cornerstonejs/cornerstone3D/blob/main/packages/tools/src/utilities/math/polyline/decimate.ts + +MIT License + +Copyright (c) 2019 Open Health Imaging Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +import { Vector3 } from '@kitware/vtk.js/types'; +import vtkLine from '@kitware/vtk.js/Common/DataModel/Line'; + +const DEFAULT_EPSILON = 0.1; + +type Line = Array; + +/** + * Ramer–Douglas–Peucker algorithm implementation to decimate a polyline + * to a similar polyline with fewer points + * + * https://en.wikipedia.org/wiki/Ramer%E2%80%93Douglas%E2%80%93Peucker_algorithm + * https://rosettacode.org/wiki/Ramer-Douglas-Peucker_line_simplification + * https://karthaus.nl/rdp/ + * + * @param polyline - Polyline to decimate + * @param epsilon - A maximum given distance 'epsilon' to decide if a point + * should or shouldn't be added the decimated polyline version. In each + * iteration the polyline is split into two polylines and the distance of each + * point from those new polylines are checked against the line that connects + * the first and last points. + * @returns Decimated polyline + */ +export default function decimate(polyline: Line, epsilon = DEFAULT_EPSILON) { + const numPoints = polyline.length; + + // The polyline must have at least a start and end points + if (numPoints < 3) { + return polyline; + } + + const epsilonSquared = epsilon * epsilon; + const partitionQueue = [[0, numPoints - 1]]; + + // Used a boolean array to set each point that will be in the decimated polyline + // because pre-allocated arrays are 3-4x faster than thousands of push() calls + // to add all points to a new array. + const polylinePointFlags = new Array(numPoints).fill(false); + + // Start and end points are always added to the decimated polyline + let numDecimatedPoints = 2; + + // Add start and end points to the decimated polyline + polylinePointFlags[0] = true; + polylinePointFlags[numPoints - 1] = true; + + // Iterative approach using a queue instead of recursion to reduce the number + // of function calls (performance) + while (partitionQueue.length) { + const [startIndex, endIndex] = partitionQueue.pop()!; + + // Return if there is no point between the start and end points + if (endIndex - startIndex === 1) { + continue; + } + + const startPoint = polyline[startIndex]; + const endPoint = polyline[endIndex]; + let maxDistSquared = -Infinity; + let maxDistIndex = -1; + + // Search for the furthest point + for (let i = startIndex + 1; i < endIndex; i++) { + const currentPoint = polyline[i]; + const { distance: distSquared } = vtkLine.distanceToLine( + currentPoint, + startPoint, + endPoint + ); + + if (distSquared > maxDistSquared) { + maxDistSquared = distSquared; + maxDistIndex = i; + } + } + + // Do not add any of the points because the fursthest one is very close to + // the line based on the epsilon value + if (maxDistSquared < epsilonSquared) { + continue; + } + + // Update the flag for the furthest point because it will be added to the + // decimated polyline + polylinePointFlags[maxDistIndex] = true; + numDecimatedPoints++; + + // Partition the points into two parts using maxDistIndex as the pivot point + // and process both sides + partitionQueue.push([maxDistIndex, endIndex]); + partitionQueue.push([startIndex, maxDistIndex]); + } + + // A pre-allocated array is 3-4x faster then multiple push() calls + const decimatedPolyline: Line = new Array(numDecimatedPoints); + + for (let srcIndex = 0, dstIndex = 0; srcIndex < numPoints; srcIndex++) { + if (polylinePointFlags[srcIndex]) { + decimatedPolyline[dstIndex++] = polyline[srcIndex]; + } + } + + return decimatedPolyline; +} diff --git a/src/vtk/PolygonWidget/index.d.ts b/src/vtk/PolygonWidget/index.d.ts index 0c793a84..513a82c6 100644 --- a/src/vtk/PolygonWidget/index.d.ts +++ b/src/vtk/PolygonWidget/index.d.ts @@ -25,6 +25,7 @@ export interface vtkPolygonWidgetState extends vtkAnnotationWidgetState { export interface vtkPolygonViewWidget extends vtkAnnotationToolWidget { getWidgetState(): vtkPolygonWidgetState; + onDraggingEvent(callback: (e: any) => void): vtkSubscription; } export interface IPolygonWidgetInitialValues diff --git a/src/vtk/PolygonWidget/state.ts b/src/vtk/PolygonWidget/state.ts index 57aa9a3f..8d956423 100644 --- a/src/vtk/PolygonWidget/state.ts +++ b/src/vtk/PolygonWidget/state.ts @@ -7,13 +7,17 @@ import { Vector3 } from '@kitware/vtk.js/types'; import vtkAnnotationWidgetState from '@/src/vtk/ToolWidgetUtils/annotationWidgetState'; import { Polygon } from '@/src/types/polygon'; import { AnnotationToolType } from '@/src/store/tools/types'; +import { getImageMetadata } from '@/src/composables/useCurrentImage'; +import { getSmallestSpacing } from '@/src/utils/frameOfReference'; import createPointState from '../ToolWidgetUtils/pointState'; import { watchState } from '../ToolWidgetUtils/utils'; +import decimate from './decimate'; export const MoveHandleLabel = 'moveHandle'; export const HandlesLabel = 'handles'; -const PIXEL_SIZE = 20; +const HANDLE_PIXEL_SIZE = 20; +const DECIMATE_PIXEL_SIZE_FACTOR = 0.2; type VtkObjectModel = { classHierarchy: string[]; @@ -65,7 +69,9 @@ function vtkPolygonWidgetState(publicAPI: any, model: any) { }; vtkWidgetState.extend(handlePublicAPI, handleModel, {}); visibleMixin.extend(handlePublicAPI, handleModel, { visible: true }); - scale1Mixin.extend(handlePublicAPI, handleModel, { scale1: PIXEL_SIZE }); + scale1Mixin.extend(handlePublicAPI, handleModel, { + scale1: HANDLE_PIXEL_SIZE, + }); const handleModelPromoted = handleModel as HandleModel; handleModelPromoted.classHierarchy.push('vtkPolygonHandleState'); @@ -110,16 +116,34 @@ function vtkPolygonWidgetState(publicAPI: any, model: any) { } }; + const addPointsAsHandles = () => { + getTool().points.forEach((point) => { + const handle = publicAPI.addHandle({ addPoint: false }); + handle.setOrigin(point); + }); + }; + publicAPI.getPlacing = () => getTool().placing; + publicAPI.setPlacing = (placing: boolean) => { - getTool().placing = placing; + const tool = getTool(); + tool.placing = placing; + if (placing) return; + + // Decimate points + const imageMeta = getImageMetadata(tool.imageID); + const pixelScale = getSmallestSpacing(tool.frameOfReference, imageMeta); + const optimizedLine = decimate( + tool.points, + pixelScale * DECIMATE_PIXEL_SIZE_FACTOR + ); + publicAPI.clearHandles(); + tool.points = optimizedLine; + addPointsAsHandles(); }; // Setup after deserialization - getTool().points.forEach((point: Vector3) => { - const handle = publicAPI.addHandle({ addPoint: false }); - handle.setOrigin(point); - }); + addPointsAsHandles(); } const defaultValues = (initialValues: any) => ({