FreeForm lasso mode

This commit is contained in:
stan
2025-10-01 20:28:29 +01:00
parent fec88785d7
commit 96047f3e3e
9 changed files with 351 additions and 6 deletions

View File

@@ -0,0 +1,52 @@
import React, { useMemo } from 'react';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { createSmoothPath } from 'src/utils';
export const FreehandLasso = () => {
const mode = useUiStateStore((state) => {
return state.mode;
});
const rendererSize = useUiStateStore((state) => {
return state.rendererEl?.getBoundingClientRect();
});
const smoothPath = useMemo(() => {
if (mode.type !== 'FREEHAND_LASSO' || mode.path.length < 2) {
return '';
}
return createSmoothPath(mode.path);
}, [mode]);
if (mode.type !== 'FREEHAND_LASSO' || mode.path.length < 2) {
return null;
}
const width = rendererSize?.width || 0;
const height = rendererSize?.height || 0;
return (
<svg
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 1000
}}
viewBox={`0 0 ${width} ${height}`}
>
<path
d={smoothPath}
fill="rgba(33, 150, 243, 0.15)"
stroke="#2196f3"
strokeWidth={2}
strokeDasharray="8 4"
strokeLinejoin="round"
strokeLinecap="round"
/>
</svg>
);
};

View File

@@ -13,6 +13,7 @@ import { SizeIndicator } from 'src/components/DebugUtils/SizeIndicator';
import { SceneLayer } from 'src/components/SceneLayer/SceneLayer';
import { TransformControlsManager } from 'src/components/TransformControlsManager/TransformControlsManager';
import { Lasso } from 'src/components/Lasso/Lasso';
import { FreehandLasso } from 'src/components/FreehandLasso/FreehandLasso';
import { useScene } from 'src/hooks/useScene';
import { RendererProps } from 'src/types/rendererProps';
@@ -63,6 +64,7 @@ export const Renderer = ({ showGrid, backgroundColor }: RendererProps) => {
<SceneLayer>
<Lasso />
</SceneLayer>
<FreehandLasso />
<Box
sx={{
position: 'absolute',

View File

@@ -10,7 +10,8 @@ import {
Undo as UndoIcon,
Redo as RedoIcon,
Help as HelpIcon,
HighlightAltOutlined as LassoIcon
HighlightAltOutlined as LassoIcon,
GestureOutlined as FreehandLassoIcon
} from '@mui/icons-material';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { IconButton } from 'src/components/IconButton/IconButton';
@@ -106,6 +107,20 @@ export const ToolMenu = () => {
}}
isActive={mode.type === 'LASSO'}
/>
<IconButton
name={`Freehand lasso${hotkeys.freehandLasso ? ` (${hotkeys.freehandLasso.toUpperCase()})` : ''}`}
Icon={<FreehandLassoIcon />}
onClick={() => {
uiStateStoreActions.setMode({
type: 'FREEHAND_LASSO',
showCursor: true,
path: [],
selection: null,
isDragging: false
});
}}
isActive={mode.type === 'FREEHAND_LASSO'}
/>
<IconButton
name={`Pan${hotkeys.pan ? ` (${hotkeys.pan.toUpperCase()})` : ''}`}
Icon={<PanToolIcon />}

View File

@@ -8,6 +8,7 @@ export interface HotkeyMapping {
connector: string | null;
text: string | null;
lasso: string | null;
freehandLasso: string | null;
}
export const HOTKEY_PROFILES: Record<HotkeyProfile, HotkeyMapping> = {
@@ -18,7 +19,8 @@ export const HOTKEY_PROFILES: Record<HotkeyProfile, HotkeyMapping> = {
rectangle: 'r',
connector: 't',
text: 'y',
lasso: 'l'
lasso: 'l',
freehandLasso: 'f'
},
smnrct: {
select: 's',
@@ -27,7 +29,8 @@ export const HOTKEY_PROFILES: Record<HotkeyProfile, HotkeyMapping> = {
rectangle: 'r',
connector: 'c',
text: 't',
lasso: 'l'
lasso: 'l',
freehandLasso: 'f'
},
none: {
select: null,
@@ -36,7 +39,8 @@ export const HOTKEY_PROFILES: Record<HotkeyProfile, HotkeyMapping> = {
rectangle: null,
connector: null,
text: null,
lasso: null
lasso: null,
freehandLasso: null
}
};

View File

@@ -0,0 +1,170 @@
import { produce } from 'immer';
import { ModeActions, ItemReference, Coords } from 'src/types';
import { screenToIso, isPointInPolygon } from 'src/utils';
// Helper to find all items whose centers are within the freehand polygon
const getItemsInFreehandBounds = (
pathTiles: Coords[],
scene: any
): ItemReference[] => {
const items: ItemReference[] = [];
if (pathTiles.length < 3) return items;
// Check all nodes/items
scene.items.forEach((item: any) => {
if (isPointInPolygon(item.tile, pathTiles)) {
items.push({ type: 'ITEM', id: item.id });
}
});
// Check all rectangles (check center point)
scene.rectangles.forEach((rectangle: any) => {
const centerX = (rectangle.from.x + rectangle.to.x) / 2;
const centerY = (rectangle.from.y + rectangle.to.y) / 2;
const center = { x: centerX, y: centerY };
if (isPointInPolygon(center, pathTiles)) {
items.push({ type: 'RECTANGLE', id: rectangle.id });
}
});
// Check all text boxes
scene.textBoxes.forEach((textBox: any) => {
if (isPointInPolygon(textBox.tile, pathTiles)) {
items.push({ type: 'TEXTBOX', id: textBox.id });
}
});
return items;
};
export const FreehandLasso: ModeActions = {
mousemove: ({ uiState, scene }) => {
if (uiState.mode.type !== 'FREEHAND_LASSO' || !uiState.mouse.mousedown) return;
// If user is dragging an existing selection, switch to DRAG_ITEMS mode
if (uiState.mode.isDragging && uiState.mode.selection) {
uiState.actions.setMode({
type: 'DRAG_ITEMS',
showCursor: true,
items: uiState.mode.selection.items,
isInitialMovement: true
});
return;
}
// User is drawing the freehand path - collect screen coordinates
const newScreenPoint = uiState.mouse.position.screen;
uiState.actions.setMode(
produce(uiState.mode, (draft) => {
if (draft.type === 'FREEHAND_LASSO') {
// Add point to path if it's far enough from the last point (throttle)
const lastPoint = draft.path[draft.path.length - 1];
if (!lastPoint ||
Math.abs(newScreenPoint.x - lastPoint.x) > 5 ||
Math.abs(newScreenPoint.y - lastPoint.y) > 5) {
draft.path.push(newScreenPoint);
}
}
})
);
},
mousedown: ({ uiState }) => {
if (uiState.mode.type !== 'FREEHAND_LASSO') return;
// If there's an existing selection, check if click is within it
if (uiState.mode.selection) {
// Convert click position to tile
const clickTile = uiState.mouse.position.tile;
const isWithinSelection = isPointInPolygon(
clickTile,
uiState.mode.selection.pathTiles
);
if (isWithinSelection) {
// Clicked within selection - prepare to drag
uiState.actions.setMode(
produce(uiState.mode, (draft) => {
if (draft.type === 'FREEHAND_LASSO') {
draft.isDragging = true;
}
})
);
return;
}
// Clicked outside selection - clear it and start new path
uiState.actions.setMode(
produce(uiState.mode, (draft) => {
if (draft.type === 'FREEHAND_LASSO') {
draft.path = [uiState.mouse.position.screen];
draft.selection = null;
draft.isDragging = false;
}
})
);
return;
}
// Start a new path
uiState.actions.setMode(
produce(uiState.mode, (draft) => {
if (draft.type === 'FREEHAND_LASSO') {
draft.path = [uiState.mouse.position.screen];
draft.selection = null;
draft.isDragging = false;
}
})
);
},
mouseup: ({ uiState, scene }) => {
if (uiState.mode.type !== 'FREEHAND_LASSO') return;
// If we've drawn a path, convert to tiles and find items
if (uiState.mode.path.length >= 3 && !uiState.mode.selection) {
const rendererSize = uiState.rendererEl?.getBoundingClientRect();
if (!rendererSize) return;
// Convert screen path to tile coordinates
const pathTiles = uiState.mode.path.map((screenPoint) => {
return screenToIso({
mouse: screenPoint,
zoom: uiState.zoom,
scroll: uiState.scroll,
rendererSize: {
width: rendererSize.width,
height: rendererSize.height
}
});
});
// Find all items within the freehand polygon
const items = getItemsInFreehandBounds(pathTiles, scene);
uiState.actions.setMode(
produce(uiState.mode, (draft) => {
if (draft.type === 'FREEHAND_LASSO') {
draft.selection = {
pathTiles,
items
};
draft.isDragging = false;
}
})
);
} else {
// Reset dragging state but keep selection if it exists
uiState.actions.setMode(
produce(uiState.mode, (draft) => {
if (draft.type === 'FREEHAND_LASSO') {
draft.isDragging = false;
}
})
);
}
}
};

View File

@@ -18,6 +18,7 @@ import { Pan } from './modes/Pan';
import { PlaceIcon } from './modes/PlaceIcon';
import { TextBox } from './modes/TextBox';
import { Lasso } from './modes/Lasso';
import { FreehandLasso } from './modes/FreehandLasso';
import { usePanHandlers } from './usePanHandlers';
const modes: { [k in string]: ModeActions } = {
@@ -29,7 +30,8 @@ const modes: { [k in string]: ModeActions } = {
PAN: Pan,
PLACE_ICON: PlaceIcon,
TEXTBOX: TextBox,
LASSO: Lasso
LASSO: Lasso,
FREEHAND_LASSO: FreehandLasso
};
const getModeFunction = (mode: ModeActions, e: SlimMouseEvent) => {
@@ -172,6 +174,15 @@ export const useInteractionManager = () => {
selection: null,
isDragging: false
});
} else if (hotkeyMapping.freehandLasso && key === hotkeyMapping.freehandLasso) {
e.preventDefault();
uiState.actions.setMode({
type: 'FREEHAND_LASSO',
showCursor: true,
path: [],
selection: null,
isDragging: false
});
}
};

View File

@@ -106,6 +106,17 @@ export interface LassoMode {
isDragging: boolean;
}
export interface FreehandLassoMode {
type: 'FREEHAND_LASSO';
showCursor: boolean;
path: Coords[]; // Screen coordinates of the drawn path
selection: {
pathTiles: Coords[]; // Tile coordinates of the path points
items: ItemReference[];
} | null;
isDragging: boolean;
}
export type Mode =
| InteractionsDisabled
| CursorMode
@@ -116,7 +127,8 @@ export type Mode =
| TransformRectangleMode
| DragItemsMode
| TextBoxMode
| LassoMode;
| LassoMode
| FreehandLassoMode;
// End mode types
export interface Scroll {

View File

@@ -6,3 +6,4 @@ export * from './renderer';
export * from './exportOptions';
export * from './model';
export * from './findNearestUnoccupiedTile';
export * from './pointInPolygon';

View File

@@ -0,0 +1,78 @@
import { Coords } from 'src/types';
/**
* Ray casting algorithm to determine if a point is inside a polygon
* @param point - The point to check (tile coordinates)
* @param polygon - Array of vertices defining the polygon (tile coordinates)
* @returns true if the point is inside the polygon
*/
export const isPointInPolygon = (point: Coords, polygon: Coords[]): boolean => {
if (polygon.length < 3) return false;
let inside = false;
const x = point.x;
const y = point.y;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].x;
const yi = polygon[i].y;
const xj = polygon[j].x;
const yj = polygon[j].y;
const intersect =
yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
if (intersect) inside = !inside;
}
return inside;
};
/**
* Convert an array of screen coordinates to tile coordinates using the screenToIso function
*/
export const screenPathToTilePath = (
screenPath: Coords[],
screenToIsoFn: (coords: Coords) => Coords
): Coords[] => {
return screenPath.map((point) => screenToIsoFn(point));
};
/**
* Create a smooth SVG path from a series of points using quadratic curves
* @param points - Array of screen coordinates
* @returns SVG path string
*/
export const createSmoothPath = (points: Coords[]): string => {
if (points.length < 2) return '';
let path = `M ${points[0].x},${points[0].y}`;
// Use quadratic bezier curves for smooth lines
for (let i = 1; i < points.length; i++) {
const current = points[i];
const previous = points[i - 1];
// Calculate control point as midpoint
const cpX = (previous.x + current.x) / 2;
const cpY = (previous.y + current.y) / 2;
if (i === 1) {
// First segment - line to control point, then curve
path += ` L ${cpX},${cpY}`;
} else {
// Subsequent segments - quadratic curve
path += ` Q ${previous.x},${previous.y} ${cpX},${cpY}`;
}
}
// Complete the curve to the last point
const lastPoint = points[points.length - 1];
const secondLastPoint = points[points.length - 2];
path += ` Q ${secondLastPoint.x},${secondLastPoint.y} ${lastPoint.x},${lastPoint.y}`;
// Close the path
path += ' Z';
return path;
};