修复绘制工具选区和分类应用逻辑

- 切换到创建多边形、创建矩形或创建圆时同步清空本地和全局 mask 选区,避免右侧语义分类树误改旧 mask。

- 手工创建多边形、矩形、圆后自动选中新创建的 mask,方便继续改类、编辑或保存。

- Esc 统一清空当前 mask 选区和临时绘制状态,让画笔可以在无选区状态下新建独立 mask。

- 画笔合并、新建和橡皮擦扣除后同步更新全局选区,保持右侧面板状态一致。

- 补充 CanvasArea 和 OntologyInspector 回归测试,覆盖创建工具清选区、新建自动选中、Esc 后画笔新建、无选区分类不改已有 mask。

- 更新前端审计、需求冻结、测试计划和 AGENTS 文档,记录无选区语义分类与绘制选区规则。
This commit is contained in:
2026-05-04 03:45:51 +08:00
parent b943f5e184
commit b7de163054
7 changed files with 158 additions and 21 deletions

View File

@@ -1562,6 +1562,7 @@ describe('CanvasArea', () => {
segmentation: [[120, 80, 260, 80, 260, 200, 120, 200]],
bbox: [120, 80, 140, 120],
}));
expect(useStore.getState().selectedMaskIds).toEqual([useStore.getState().masks[0].id]);
useStore.getState().undoMasks();
expect(useStore.getState().masks).toEqual([]);
@@ -1592,6 +1593,34 @@ describe('CanvasArea', () => {
}),
}));
expect(useStore.getState().masks[0].segmentation?.[0]).toHaveLength(64);
expect(useStore.getState().selectedMaskIds).toEqual([useStore.getState().masks[0].id]);
});
it('clears the selected mask when switching to manual creation tools', async () => {
useStore.setState({
selectedMaskIds: ['m1'],
masks: [
{
id: 'm1',
frameId: 'frame-1',
pathData: 'M 10 10 L 80 10 L 80 80 L 10 80 Z',
label: 'Existing',
color: '#06b6d4',
segmentation: [[10, 10, 80, 10, 80, 80, 10, 80]],
},
],
});
const { rerender } = render(<CanvasArea activeTool="create_polygon" frame={frame} />);
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual([]));
act(() => useStore.getState().setSelectedMaskIds(['m1']));
rerender(<CanvasArea activeTool="create_rectangle" frame={frame} />);
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual([]));
act(() => useStore.getState().setSelectedMaskIds(['m1']));
rerender(<CanvasArea activeTool="create_circle" frame={frame} />);
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual([]));
});
it('creates a brush mask when a semantic class is selected', () => {
@@ -1777,6 +1806,7 @@ describe('CanvasArea', () => {
source: 'manual',
shape: '多边形',
}));
expect(useStore.getState().selectedMaskIds).toEqual([useStore.getState().masks[0].id]);
});
it('closes a clicked polygon by clicking the first node again', () => {
@@ -1803,6 +1833,51 @@ describe('CanvasArea', () => {
.filter((element) => element.getAttribute('data-fill') === '#facc15')).toHaveLength(0);
});
it('clears selected masks with Escape so brush can create a new unmerged mask', async () => {
useStore.setState({
activeTemplateId: '2',
activeClass: { id: 'c2', name: '肝脏', color: '#00ff00', zIndex: 30, maskId: 2 },
activeClassId: 'c2',
selectedMaskIds: ['m1'],
masks: [
{
id: 'm1',
frameId: 'frame-1',
pathData: 'M 100 70 L 150 70 L 150 120 L 100 120 Z',
label: '旧标签',
color: '#ff0000',
classId: 'c1',
segmentation: [[100, 70, 150, 70, 150, 120, 100, 120]],
area: 2500,
},
],
});
const { rerender } = render(<CanvasArea activeTool="move" frame={frame} />);
fireEvent.keyDown(window, { key: 'Escape' });
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual([]));
rerender(<CanvasArea activeTool="brush" frame={frame} />);
const stage = screen.getByTestId('konva-stage');
fireEvent.mouseDown(stage, { clientX: 300, clientY: 220 });
fireEvent.mouseMove(stage, { clientX: 330, clientY: 240 });
fireEvent.mouseUp(stage, { clientX: 360, clientY: 260 });
expect(useStore.getState().masks).toHaveLength(2);
expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
id: 'm1',
label: '旧标签',
color: '#ff0000',
}));
expect(useStore.getState().masks[1]).toEqual(expect.objectContaining({
label: '肝脏',
color: '#00ff00',
classId: 'c2',
classMaskId: 2,
}));
expect(useStore.getState().selectedMaskIds).toEqual([useStore.getState().masks[1].id]);
});
it('shows contextual guidance for boolean selection ordering', () => {
useStore.setState({
masks: [

View File

@@ -602,6 +602,7 @@ export function CanvasArea({
const isBooleanTool = BOOLEAN_TOOLS.has(effectiveTool);
const isPaintTool = PAINT_TOOLS.has(effectiveTool);
const isPolygonEditTool = effectiveTool === 'move' || effectiveTool === EDIT_POLYGON_TOOL;
const canKeepMaskSelection = isPolygonEditTool || isBooleanTool || isPaintTool;
const showSelectedMaskVertices = Boolean(selectedMask && (isPolygonEditTool || isPaintTool));
const activePaintSize = effectiveTool === ERASER_TOOL ? eraserSize : brushSize;
const activePaintRadius = Math.max(2, activePaintSize / 2);
@@ -751,13 +752,14 @@ export function CanvasArea({
lastPaintPointRef.current = null;
setPolygonPoints([]);
setSelectedVertexIndex(null);
if (!isPolygonEditTool && !isBooleanTool && !isPaintTool) {
if (!canKeepMaskSelection) {
setSelectedMaskId(null);
setSelectedMaskIds([]);
setGlobalSelectedMaskIds([]);
setSelectedPolygonIndex(0);
}
if (!isBooleanTool) setPendingBooleanFrameIds(null);
}, [effectiveTool, isBooleanTool, isPaintTool, isPolygonEditTool, setPaintStrokePoints]);
}, [canKeepMaskSelection, effectiveTool, isBooleanTool, setGlobalSelectedMaskIds, setPaintStrokePoints]);
useEffect(() => {
if (previousFrameIdRef.current === frame?.id) return;
@@ -784,6 +786,9 @@ export function CanvasArea({
useEffect(() => {
const currentGlobalSelectedIds = useStore.getState().selectedMaskIds;
if (!canKeepMaskSelection) {
return;
}
const validLocalSelectedIds = selectedMaskIds.filter((id) => (
frameMasks.some((mask) => mask.id === id)
));
@@ -802,11 +807,14 @@ export function CanvasArea({
if (!isSameSelection) {
setGlobalSelectedMaskIds(nextSelectedMaskIds);
}
}, [frameMasks, selectedMaskIds, setGlobalSelectedMaskIds]);
}, [canKeepMaskSelection, frameMasks, selectedMaskIds, setGlobalSelectedMaskIds]);
useEffect(() => () => setGlobalSelectedMaskIds([]), [setGlobalSelectedMaskIds]);
useEffect(() => {
if (!canKeepMaskSelection) {
return;
}
if (!selectedMaskId) {
const validGlobalSelectedIds = useStore.getState().selectedMaskIds.filter((id) => (
frameMasks.some((mask) => mask.id === id)
@@ -833,7 +841,7 @@ export function CanvasArea({
setSelectedPolygonIndex(0);
setSelectedVertexIndex(null);
}
}, [frame?.id, frameMasks, masks, selectedMaskId, selectedMaskIds, setGlobalSelectedMaskIds]);
}, [canKeepMaskSelection, frame?.id, frameMasks, masks, selectedMaskId, selectedMaskIds, setGlobalSelectedMaskIds]);
const handleWheel = (e: any) => {
e.evt.preventDefault();
@@ -903,7 +911,12 @@ export function CanvasArea({
metadata: { source: 'manual', shape },
};
addMask(mask);
}, [activeClass, activeTemplateId, addMask, frame?.id]);
setSelectedMaskId(mask.id);
setSelectedMaskIds([mask.id]);
setGlobalSelectedMaskIds([mask.id]);
setSelectedPolygonIndex(0);
setSelectedVertexIndex(null);
}, [activeClass, activeTemplateId, addMask, frame?.id, setGlobalSelectedMaskIds]);
const createManualMaskFromGeometry = useCallback((shape: string, geometry: MultiPolygon): Mask | null => {
if (!frame?.id || !activeClass) return null;
@@ -1113,9 +1126,10 @@ export function CanvasArea({
}
setSelectedMaskId(null);
setSelectedMaskIds([]);
setGlobalSelectedMaskIds([]);
setSelectedPolygonIndex(0);
setSelectedVertexIndex(null);
}, [masks, onDeleteMaskAnnotations, samCandidateMaskId, setMasks]);
}, [masks, onDeleteMaskAnnotations, samCandidateMaskId, setGlobalSelectedMaskIds, setMasks]);
const applyPaintStroke = useCallback((tool: string | null, strokePoints: CanvasPoint[]) => {
if (!frame?.id || strokePoints.length === 0) return;
@@ -1159,6 +1173,7 @@ export function CanvasArea({
setMasks(masks.map((mask) => (mask.id === selectedMask.id ? nextMask : mask)));
setSelectedMaskId(selectedMask.id);
setSelectedMaskIds([selectedMask.id]);
setGlobalSelectedMaskIds([selectedMask.id]);
setSelectedVertexIndex(null);
return;
}
@@ -1167,6 +1182,7 @@ export function CanvasArea({
if (nextMask) {
setSelectedMaskId(nextMask.id);
setSelectedMaskIds([nextMask.id]);
setGlobalSelectedMaskIds([nextMask.id]);
setSelectedPolygonIndex(0);
setSelectedVertexIndex(null);
}
@@ -1194,6 +1210,7 @@ export function CanvasArea({
setMasks(masks.map((mask) => (mask.id === selectedMask.id ? nextMask : mask)));
setSelectedMaskId(selectedMask.id);
setSelectedMaskIds([selectedMask.id]);
setGlobalSelectedMaskIds([selectedMask.id]);
setSelectedVertexIndex(null);
}
}, [
@@ -1212,6 +1229,7 @@ export function CanvasArea({
image?.width,
masks,
selectedMask,
setGlobalSelectedMaskIds,
setMasks,
stageSize.height,
stageSize.width,
@@ -1392,6 +1410,21 @@ export function CanvasArea({
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
setPolygonPoints([]);
setManualStart(null);
setManualCurrent(null);
setPaintStrokePoints([]);
paintToolRef.current = null;
lastPaintPointRef.current = null;
setSelectedMaskId(null);
setSelectedMaskIds([]);
setGlobalSelectedMaskIds([]);
setSelectedPolygonIndex(0);
setSelectedVertexIndex(null);
return;
}
if ((event.key === 'Delete' || event.key === 'Backspace') && selectedMask && selectedVertexIndex !== null) {
const currentPoints = segmentationToPoints(selectedMask.segmentation, selectedPolygonIndex);
if (currentPoints.length > 3) {
@@ -1417,15 +1450,11 @@ export function CanvasArea({
event.preventDefault();
finishPolygon();
}
if (event.key === 'Escape') {
event.preventDefault();
setPolygonPoints([]);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [deleteMasksById, effectiveTool, finishPolygon, isPolygonEditTool, onRequestDeleteMasks, polygonPoints, selectedMask, selectedMaskIds, selectedPolygonIndex, selectedVertexIndex, updatePolygonMask]);
}, [deleteMasksById, effectiveTool, finishPolygon, isPolygonEditTool, onRequestDeleteMasks, polygonPoints, selectedMask, selectedMaskIds, selectedPolygonIndex, selectedVertexIndex, setGlobalSelectedMaskIds, setPaintStrokePoints, updatePolygonMask]);
const boxRect = React.useMemo(() => {
if (!boxStart || !boxCurrent) return null;

View File

@@ -160,6 +160,39 @@ describe('OntologyInspector', () => {
}));
});
it('does not change existing masks when selecting a class without a selected mask', () => {
useStore.setState({
selectedMaskIds: [],
masks: [
{
id: 'm1',
frameId: 'frame-1',
pathData: 'M 0 0 Z',
label: '旧标签',
color: '#06b6d4',
classId: 'old-class',
saveStatus: 'saved',
saved: true,
},
],
});
render(<OntologyInspector />);
fireEvent.click(screen.getByText('肝脏'));
expect(useStore.getState().activeClassId).toBe('c2');
expect(useStore.getState().masks).toEqual([
expect.objectContaining({
id: 'm1',
label: '旧标签',
color: '#06b6d4',
classId: 'old-class',
saveStatus: 'saved',
saved: true,
}),
]);
});
it('applies the selected class to currently selected masks', () => {
useStore.setState({
selectedMaskIds: ['m1'],