Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
34 changes: 18 additions & 16 deletions src/context/roiReducer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
} from '../index.js';
import type { CommittedRoiProperties } from '../types/CommittedRoi.js';
import type { Roi, XCornerPosition, YCornerPosition } from '../types/Roi.js';
import type { UpdateRoiOptions } from '../types/actions.ts';
import { assert, assertUnreachable } from '../utilities/assert.js';
import {
changeBoxRotationCenter,
Expand All @@ -29,6 +30,7 @@ import { cancelAction } from './updaters/cancelAction.js';
import { endAction } from './updaters/endAction.js';
import { updateInitialPanZoom } from './updaters/initialPanZoom.js';
import { pointerMove } from './updaters/pointerMove.js';
import { updateCommittedRoiPosition } from './updaters/roi.ts';
import { sanitizeRois } from './updaters/sanitizeRois.js';
import {
prepareSelectedBoxForAction,
Expand Down Expand Up @@ -119,16 +121,6 @@ export interface ReactRoiState<TData = unknown> {
zoomDomain: ZoomDomain;
}

export interface UpdateRoiOptions {
/**
* Whether the update should be committed immediately.
* In both cases, the ROI will move immediately on screen.
* If set to false, the ROI will enter a mode where it can't be modified through user interactions until committed.
* Setting it to false also prevents having frequent updates to committed ROIs when those can trigger expensive operations.
*/
commit: boolean;
}

export type UpdateRoiPayload = Partial<CommittedRoiProperties> & {
id: string;
options?: UpdateRoiOptions;
Expand Down Expand Up @@ -335,7 +327,7 @@ export function roiReducer(
case 'UPDATE_ROI': {
const {
id,
options = { commit: true, boundingStrategy: 'none' },
options = { commit: true, boundaryStrategy: 'none' },
...updatedData
} = action.payload;
if (!id) return;
Expand All @@ -344,11 +336,21 @@ export function roiReducer(
if (options.commit) {
const committedRoi = draft.committedRois[index];
assert(committedRoi, 'Committed ROI not found');
Object.assign<
CommittedRoiProperties,
Partial<CommittedRoiProperties>
>(committedRoi, updatedData);
draft.rois[index] = createRoiFromCommittedRoi(committedRoi);
draft.rois[index] = createRoiFromCommittedRoi({
...committedRoi,
...updatedData,
});
try {
updateCommittedRoiPosition(draft, committedRoi, draft.rois[index], {
boundaryStrategy: options.boundaryStrategy ?? 'none',
boxStrategy: 'exact',
});
if (updatedData.data) {
committedRoi.data = updatedData.data;
}
} catch {
cancelAction(draft, { noUnselection: false }, id);
}
} else {
const roi = draft.rois[index];
const { x, y, width, height, angle, ...otherData } = updatedData;
Expand Down
9 changes: 8 additions & 1 deletion src/context/updaters/cancelAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,15 @@ import type { CancelActionPayload, ReactRoiState } from '../roiReducer.js';
export function cancelAction(
draft: Draft<ReactRoiState>,
payload: CancelActionPayload,
/**
* If you want to cancel action for a specific ROI, provide its ID here.
* Otherwise, all ROIs with non-idle actions will be cancelled.
*/
roiId?: string,
) {
const rois = draft.rois.filter((roi) => roi.action.type !== 'idle');
const rois = draft.rois.filter((roi) =>
roiId ? roi.id === roiId : roi.action.type !== 'idle',
);

if (rois.length === 0) {
return;
Expand Down
34 changes: 22 additions & 12 deletions src/context/updaters/roi.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Draft } from 'immer';

import type {
BoundaryStrategy,
CommittedBox,
CommittedRoiProperties,
Size,
Expand All @@ -14,8 +15,9 @@ import {
denormalizeBox,
normalizeBox,
} from '../../utilities/box.js';
import { rectanglesIntersect } from '../../utilities/intersection.ts';
import { getMBRBoundaries } from '../../utilities/rotate.js';
import type { ReactRoiState } from '../roiReducer.js';
import type { CommitBoxStrategy, ReactRoiState } from '../roiReducer.js';

export function boundBox(
committedBox: CommittedBox,
Expand Down Expand Up @@ -77,15 +79,15 @@ export function boundBox(
break;
}
case 'is_partially_inside': {
const mbr = getMBRBoundaries(committedBox);
// The separating axis theorem says that if any of the projections of 2 convex shapes do not overlap, then the shapes do not overlap.
if (
mbr.maxX < 0 ||
mbr.minX > targetSize.width ||
mbr.maxY < 0 ||
mbr.minY > targetSize.height
) {
// Throwing an error here will cancel the operation
const targetRectangle = {
x: 0,
y: 0,
width: targetSize.width,
height: targetSize.height,
angle: 0,
};
const intersects = rectanglesIntersect(committedBox, targetRectangle);
if (!intersects) {
throw new Error('ROI is completely outside of target boundaries');
}
break;
Expand Down Expand Up @@ -114,13 +116,21 @@ export function updateCommittedRoiPosition(
draft: ReactRoiState,
committedRoi: Draft<CommittedRoiProperties>,
roi: Draft<Roi>,
strategies?: {
boxStrategy?: CommitBoxStrategy;
boundaryStrategy?: BoundaryStrategy;
},
) {
const strategy = getBoundaryStrategyFromAction(
roi,
draft.commitRoiBoundaryStrategy,
strategies?.boundaryStrategy ?? draft.commitRoiBoundaryStrategy,
);
const normalizedBox = boundBox(
commitBox(normalizeBox(roi.box), roi.action, draft.commitRoiBoxStrategy),
commitBox(
normalizeBox(roi.box),
roi.action,
strategies?.boxStrategy ?? draft.commitRoiBoxStrategy,
),
draft.targetSize,
strategy,
);
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/useActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import { useMemo } from 'react';

import type {
CancelActionPayload,
UpdateRoiOptions,
ZoomIntoROIOptions,
} from '../context/roiReducer.js';
import { zoomAction } from '../context/updaters/zoom.js';
import type { CommittedRoi, CommittedRoiProperties } from '../index.js';
import type { UpdateRoiOptions } from '../types/actions.ts';
import type { RoiMode } from '../types/utils.js';
import type { Point } from '../utilities/point.js';

Expand All @@ -20,6 +20,7 @@ import { useRoiDispatch } from './useRoiDispatch.js';
export type UpdateData<TData = unknown> = Partial<
Omit<CommittedRoiProperties<TData>, 'id'>
>;

export function useActions<TData = unknown>() {
const roiDispatch = useRoiDispatch();
const containerRef = useRoiContainerRef();
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export * from './types/utils.js';
export * from './types/box.js';
export * from './types/CommittedRoi.js';
export * from './types/RoiList.js';
export * from './types/actions.js';

export * from './hooks/useActions.js';
export * from './hooks/useCommittedRois.js';
Expand Down
26 changes: 26 additions & 0 deletions src/types/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { BoundaryStrategy } from './utils.ts';

export type UpdateRoiOptions =
| UpdateRoiOptionsNoCommit
| UpdateRoiOptionsWithCommit;

export interface UpdateRoiOptionsNoCommit {
/**
* Whether the update should be committed immediately.
* In both cases, the ROI will move immediately on screen.
* If set to false, the ROI will enter a mode where it can't be modified through user interactions until committed.
* Setting it to false also prevents having frequent updates to committed ROIs when those can trigger expensive operations.
*/
commit: false;
}

// The inside_auto strategy is excluded because it requires to know about the nature of the action (move, resize, etc.).
export type UpdateRoiOptionsBoundaryStrategy = Exclude<
BoundaryStrategy,
'inside_auto'
>;

export interface UpdateRoiOptionsWithCommit {
commit: true;
boundaryStrategy?: UpdateRoiOptionsBoundaryStrategy;
}
4 changes: 3 additions & 1 deletion src/utilities/box.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,9 @@ export const yAxisCornerToCenter: Record<YCornerPosition, YRotationCenter> = {
center: 'center',
};

export function getRectanglePoints(box: CommittedBox): Point[] {
export function getRectanglePoints(
box: CommittedBox,
): [Point, Point, Point, Point] {
const { x, y, width, height, angle } = box;
const center: Point = { x, y };
return [
Expand Down
50 changes: 50 additions & 0 deletions src/utilities/intersection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { CommittedBox } from '../types/box.ts';

import { getRectanglePoints } from './box.ts';
import type { Point } from './point.ts';
import { dotProduct, subtract } from './point.ts';

// Check if 2 rectangles intersect based on the separating Axis Theorem (SAT)
export function rectanglesIntersect(
rect1: CommittedBox,
rect2: CommittedBox,
): boolean {
const corners1 = getRectanglePoints(rect1);
const corners2 = getRectanglePoints(rect2);

const axes = [...getAxes(corners1), ...getAxes(corners2)];

for (const axis of axes) {
const proj1 = projectOntoAxis(corners1, axis);
const proj2 = projectOntoAxis(corners2, axis);

if (proj1.max < proj2.min || proj2.max < proj1.min) {
return false; // Gap found, no intersection
}
}

return true;
}

function getAxes(corners: Point[]): Point[] {
const axes: Point[] = [];
for (let i = 0; i < corners.length; i++) {
const p1 = corners[i];
const p2 = corners[(i + 1) % corners.length];
const edge = subtract(p2, p1);
// Perpendicular axis
axes.push({ x: -edge.y, y: edge.x });
}
return axes;
}

function projectOntoAxis(
corners: Point[],
axis: Point,
): { min: number; max: number } {
const projections = corners.map((c) => dotProduct(c, axis));
return {
min: Math.min(...projections),
max: Math.max(...projections),
};
}
11 changes: 11 additions & 0 deletions src/utilities/point.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,24 @@ export function add(pointA: Point, pointB: Point): Point {
};
}

export function subtract(pointA: Point, pointB: Point): Point {
return {
x: pointA.x - pointB.x,
y: pointA.y - pointB.y,
};
}

export function mulScalar(point: Point, scalar: number): Point {
return {
x: point.x * scalar,
y: point.y * scalar,
};
}

export function dotProduct(pointA: Point, pointB: Point): number {
return pointA.x * pointB.x + pointA.y * pointB.y;
}

export function getBoundaries(points: Point[]) {
assert(points.length > 1, 'must pass at least 2 points');
let maxX = 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ import {
RoiProvider,
TargetImage,
useActions,
} from '../../../src/index.ts';
import { CommittedRoisButton } from '../../utils/CommittedRoisButton.tsx';
import { Layout } from '../../utils/Layout.tsx';
import { getInitialRois } from '../../utils/initialRois.ts';
} from '../../src/index.ts';
import { CommittedRoisButton } from '../utils/CommittedRoisButton.tsx';
import { Layout } from '../utils/Layout.tsx';
import { getInitialRois } from '../utils/initialRois.ts';

export default {
title: 'hooks/useActions',
title: 'Actions',
} as Meta;

export function AddROI() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ import {
RoiProvider,
TargetImage,
useActions,
} from '../../../src/index.ts';
import { Layout } from '../../utils/Layout.tsx';
import { getInitialRois } from '../../utils/initialRois.ts';
} from '../../src/index.ts';
import { Layout } from '../utils/Layout.tsx';
import { getInitialRois } from '../utils/initialRois.ts';

export default {
title: 'hooks/useActions',
title: 'Actions',
decorators: [
(Story) => (
<KbsProvider>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import type { Meta } from '@storybook/react-vite';
import type { ChangeEvent } from 'react';

import type { RoiMode } from '../../../src/index.ts';
import type { RoiMode } from '../../src/index.ts';
import {
RoiContainer,
RoiList,
RoiProvider,
TargetImage,
useActions,
} from '../../../src/index.ts';
import { CommittedRoisButton } from '../../utils/CommittedRoisButton.tsx';
import { Layout } from '../../utils/Layout.tsx';
} from '../../src/index.ts';
import { CommittedRoisButton } from '../utils/CommittedRoisButton.tsx';
import { Layout } from '../utils/Layout.tsx';

export default {
title: 'hooks/useActions',
title: 'Actions',
} as Meta;

export function ChangeMode() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ import {
TargetImage,
useActions,
useRoiState,
} from '../../../src/index.ts';
import { CommittedRoisButton } from '../../utils/CommittedRoisButton.tsx';
import { Layout } from '../../utils/Layout.tsx';
import { getInitialRois } from '../../utils/initialRois.ts';
} from '../../src/index.ts';
import { CommittedRoisButton } from '../utils/CommittedRoisButton.tsx';
import { Layout } from '../utils/Layout.tsx';
import { getInitialRois } from '../utils/initialRois.ts';

export default {
title: 'hooks/useActions',
title: 'Actions',
} as Meta;

export function RemoveROI() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ import {
RoiProvider,
TargetImage,
useActions,
} from '../../../src/index.ts';
import { Layout } from '../../utils/Layout.tsx';
import { getInitialRois } from '../../utils/initialRois.ts';
} from '../../src/index.ts';
import { Layout } from '../utils/Layout.tsx';
import { getInitialRois } from '../utils/initialRois.ts';

export default {
title: 'hooks/useActions',
title: 'Actions',
decorators: [
(Story) => (
<RoiProvider
Expand Down
Loading
Loading