Skip to content

Commit

Permalink
Merge pull request #617 from PaulHax/merge-polygon
Browse files Browse the repository at this point in the history
Merge polygons
  • Loading branch information
floryst authored Jul 9, 2024
2 parents f8d81b8 + 50d897f commit 72bf66b
Show file tree
Hide file tree
Showing 12 changed files with 299 additions and 17 deletions.
29 changes: 23 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"@kitware/vtk.js": "^29.0.0",
"@netlify/edge-functions": "^2.0.0",
"@sentry/vue": "^7.54.0",
"@velipso/polybool": "^1.1.0",
"@vueuse/core": "^10.7.0",
"core-js": "3.22.5",
"deep-equal": "^2.0.5",
Expand Down
12 changes: 12 additions & 0 deletions patches/@velipso+polybool+1.1.0.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
diff --git a/node_modules/@velipso/polybool/package.json b/node_modules/@velipso/polybool/package.json
index c0bf880..00899ce 100644
--- a/node_modules/@velipso/polybool/package.json
+++ b/node_modules/@velipso/polybool/package.json
@@ -11,6 +11,7 @@
"require": "./dist/polybool.cjs.js"
}
},
+ "types": "dist/polybool.d.ts",
"scripts": {
"test": "tsx src/polybool.test.ts",
"build": "rollup -c && rollup -c rollup.config.cjs.js",
4 changes: 4 additions & 0 deletions src/components/SliceViewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@
:manipulator-constructor="vtkMouseCameraTrackballPanManipulator"
:manipulator-props="{ button: 1, shift: true }"
></vtk-mouse-interaction-manipulator>
<vtk-mouse-interaction-manipulator
:manipulator-constructor="vtkMouseCameraTrackballPanManipulator"
:manipulator-props="{ button: 2 }"
></vtk-mouse-interaction-manipulator>
<vtk-mouse-interaction-manipulator
v-if="currentTool === Tools.Zoom"
:manipulator-constructor="
Expand Down
1 change: 1 addition & 0 deletions src/components/tools/AnnotationContextMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ const hideToolFromContextMenu = () => {
</v-list-item>

<!-- Optional items below stable items for muscle memory -->
<slot></slot>
<v-list-item
v-for="action in contextMenu.widgetActions"
@click="action.func"
Expand Down
37 changes: 35 additions & 2 deletions src/components/tools/polygon/PolygonTool.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,24 @@
@widgetHover="onHover(tool.id, $event)"
/>
</svg>
<annotation-context-menu ref="contextMenu" :tool-store="activeToolStore" />
<annotation-context-menu ref="contextMenu" :tool-store="activeToolStore">
<v-tooltip
:disabled="mergePossible"
text="Shift select multiple polygons that overlap and have the same label."
>
<template v-slot:activator="{ props }">
<div v-bind="props">
<v-list-item @click="mergeTools" :disabled="!mergePossible">
<template v-slot:prepend>
<v-icon>mdi-vector-union</v-icon>
</template>

<v-list-item-title>Merge Polygons</v-list-item-title>
</v-list-item>
</div>
</template>
</v-tooltip>
</annotation-context-menu>
<annotation-info :info="overlayInfo" :tool-store="activeToolStore" />
</div>
</template>
Expand All @@ -37,9 +54,10 @@ import {
import AnnotationContextMenu from '@/src/components/tools/AnnotationContextMenu.vue';
import AnnotationInfo from '@/src/components/tools/AnnotationInfo.vue';
import { useFrameOfReference } from '@/src/composables/useFrameOfReference';
import { actionToKey } from '@/src/composables/useKeyboardShortcuts';
import { Maybe } from '@/src/types';
import { useSliceInfo } from '@/src/composables/useSliceInfo';
import { watchImmediate } from '@vueuse/core';
import { useMagicKeys, watchImmediate } from '@vueuse/core';
import PolygonWidget2D from './PolygonWidget2D.vue';
const useActiveToolStore = usePolygonStore;
Expand Down Expand Up @@ -109,10 +127,19 @@ export default defineComponent({
placingTool.remove();
});
const keys = useMagicKeys();
const mergeKey = computed(
() => keys[actionToKey.value.mergeNewPolygon].value
);
const onToolPlaced = () => {
if (imageId.value) {
const newToolId = placingTool.id.value;
placingTool.commit();
placingTool.add();
if (mergeKey.value && newToolId) {
activeToolStore.mergeWithOtherTools(newToolId);
}
}
};
Expand All @@ -124,12 +151,18 @@ export default defineComponent({
const { onHover, overlayInfo } = useHover(currentTools, slice);
const mergePossible = computed(
() => activeToolStore.mergeableTools.length >= 1
);
return {
tools: currentTools,
placingToolID: placingTool.id,
onToolPlaced,
contextMenu,
openContextMenu,
mergeTools: activeToolStore.mergeSelectedTools,
mergePossible,
activeToolStore,
onHover,
overlayInfo,
Expand Down
2 changes: 2 additions & 0 deletions src/composables/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,7 @@ export const ACTION_TO_FUNC = {
decrementLabel: applyLabelOffset(-1),
incrementLabel: applyLabelOffset(1),

mergeNewPolygon: () => {}, // acts as a modifier key rather than immediate effect, so no-op

showKeyboardShortcuts,
} as const satisfies Record<Action, () => void>;
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ export const ACTION_TO_KEY = {
crosshairs: 'c',
crop: 'b',
polygon: 'g',
mergeNewPolygon: 'Shift',
select: 's',

decrementLabel: 'q',
Expand Down
4 changes: 4 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ export const ACTIONS = {
readable: 'Activate next Label',
},

mergeNewPolygon: {
readable: 'Hold down to merge new polygons with overlapping polygons',
},

showKeyboardShortcuts: {
readable: 'Show keyboard shortcuts dialog',
},
Expand Down
152 changes: 151 additions & 1 deletion src/store/tools/polygons.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import type { Vector3 } from '@kitware/vtk.js/types';
import polybool, { Polygon as LibPolygon } from '@velipso/polybool';
import type { Vector3, Vector2 } from '@kitware/vtk.js/types';
import { computed } from 'vue';
import {
ToolSelection,
useToolSelectionStore,
} from '@/src/store/tools/toolSelection';
import { AnnotationToolType } from '@/src/store/tools/types';
import { POLYGON_LABEL_DEFAULTS } from '@/src/config';
import { Manifest, StateFile } from '@/src/io/state-file/schema';
import { getPlaneTransforms } from '@/src/utils/frameOfReference';
import { ToolID } from '@/src/types/annotation-tool';
import { defineAnnotationToolStore } from '@/src/utils/defineAnnotationToolStore';
import { useAnnotationTool } from './useAnnotationTool';
Expand All @@ -22,6 +30,145 @@ export const usePolygonStore = defineAnnotationToolStore('polygon', () => {
return tool.points;
}

// -- merge tool helpers -- //
type Tool = (typeof toolAPI.tools.value)[number];

const toPolyLibStructure = (polygon: Tool) => {
const { to2D } = getPlaneTransforms(polygon.frameOfReference);
if (polygon.points.length === 0) return undefined; // empty polygons are invalid
return {
regions: [polygon.points.map(to2D)],
inverted: false,
};
};

const polygonsOverlap = (a: Tool, b: Tool) => {
const [aGeo, bGeo] = [a, b].map(toPolyLibStructure);
if (!aGeo || !bGeo) return false;
return polybool.intersect(aGeo, bGeo).regions.length > 0;
};

const pointEquals = (a: Vector2, b: Vector2) =>
a[0] === b[0] && a[1] === b[1];

// After union, regions will have shared points because we require overlap to union.
// Create one region/ring by splicing in the next region at the common point.
const mergeRegions = (regions: Array<Array<Vector2>>) => {
const [mergedRegion, ...candidates] = regions;

while (candidates.length > 0) {
let regionIndex = 0;
let mergedCommonPointIndex = 0;
let candidateCommonPointIndex = 0;
for (let i = 0; i < candidates.length; i++) {
const candidate = candidates[i];
let candidatePointIndex = 0;
const commonPointIndex = mergedRegion.findIndex((point) =>
candidate.some((nextPoint, index) => {
candidatePointIndex = index;
return pointEquals(point, nextPoint);
})
);
if (commonPointIndex !== -1) {
regionIndex = i;
mergedCommonPointIndex = commonPointIndex;
candidateCommonPointIndex = candidatePointIndex;
break;
}
}
const [toMerge] = candidates.splice(regionIndex, 1);
const oldStart = toMerge.splice(0, candidateCommonPointIndex);
const startWithCommonPoint = [...toMerge, ...oldStart];
mergedRegion.splice(mergedCommonPointIndex, 0, ...startWithCommonPoint);
}

return mergedRegion;
};

const mergePolygons = (polygons: Array<Tool>) => {
const libPolygons = polygons.map(toPolyLibStructure) as Array<LibPolygon>;
if (libPolygons.some((p) => p === undefined))
throw new Error('Trying to merge invalid polygons');

let segments = polybool.segments(libPolygons[0]);
for (let i = 1; i < libPolygons.length; i++) {
const seg2 = polybool.segments(libPolygons[i]);
const comb = polybool.combine(segments, seg2);
segments = polybool.selectUnion(comb);
}
const unionPoly = polybool.polygon(segments);

const firstTool = polygons[0];
const { to3D } = getPlaneTransforms(firstTool.frameOfReference);

const points = mergeRegions(unionPoly.regions).map(to3D);

// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { id: _, ...toolProps } = polygons[0];
const mergedTool = {
...toolProps,
points,
};
return mergedTool;
};

const sameSliceAndLabel = (a: Tool, b: Tool) =>
a.label === b.label &&
a.slice === b.slice &&
a.frameOfReference === b.frameOfReference;

const mergable = (a: Tool, b: Tool) => {
if (!sameSliceAndLabel(a, b)) return false;
return polygonsOverlap(a, b);
};
// --- //

const selectionStore = useToolSelectionStore();

const isPolygonTool = (tool: ToolSelection) =>
tool.type === AnnotationToolType.Polygon;

const mergeableTools = computed(() => {
const selectedPolygons = selectionStore.selection
.filter(isPolygonTool)
.map((sel) => {
return toolAPI.toolByID.value[sel.id];
});
if (selectedPolygons.length < 2) return [];
const [first, ...rest] = selectedPolygons;
const overlapping = [first];
while (rest.length > 0) {
const overlappingIndex = rest.findIndex((candidate) =>
overlapping.some((inTool) => mergable(candidate, inTool))
);
if (overlappingIndex < 0) return []; // selected tool is not overlapping
// use splice to remove the overlapping tool from the rest array
overlapping.push(...rest.splice(overlappingIndex, 1));
}
return overlapping;
});

function mergeTools(mergeGroup: Tool[]) {
const mergedTool = mergePolygons(mergeGroup);
toolAPI.addTool(mergedTool);
mergeGroup.map(({ id }) => id).forEach(toolAPI.removeTool);
}

function mergeSelectedTools() {
mergeTools(mergeableTools.value);
}

function mergeWithOtherTools(id: ToolID) {
const lastTool = toolAPI.toolByID.value[id];
const olderTools = toolAPI.tools.value.filter(
(tool) => tool !== lastTool && !tool.placing
);
if (!lastTool || olderTools.length === 0) return;
const mergeable = olderTools.filter((older) => mergable(older, lastTool));
if (mergeable.length === 0) return;
mergeTools([lastTool, ...mergeable]);
}

// --- serialization --- //

function serialize(state: StateFile) {
Expand All @@ -35,6 +182,9 @@ export const usePolygonStore = defineAnnotationToolStore('polygon', () => {
return {
...toolAPI,
getPoints,
mergeableTools,
mergeSelectedTools,
mergeWithOtherTools,
serialize,
deserialize,
};
Expand Down
Loading

0 comments on commit 72bf66b

Please sign in to comment.