让新建绘制并入选中遮罩

- 多边形、矩形、圆和画笔在当前已有选中 mask 时,将新几何 union 并入选中 mask,即使区域不重叠也保持为同一个多 polygon mask。

- 无选中 mask 时仍按原新建流程创建并自动选中新 mask;画笔无选区时仍要求右侧语义分类树有选中类别。

- 补充 CanvasArea 回归测试,覆盖创建工具保留选区、分离矩形并入选中 mask、画笔无 active class 时并入选中 mask。

- 更新前端审计、需求冻结、设计冻结、状态机、测试计划和项目指南文档。
This commit is contained in:
2026-05-04 04:57:53 +08:00
parent 1971640a67
commit 85de1ffbb2
9 changed files with 118 additions and 44 deletions

View File

@@ -1661,7 +1661,7 @@ describe('CanvasArea', () => {
.filter((element) => element.getAttribute('data-fill') === '#ffffff')).toHaveLength(32);
});
it('clears the selected mask when switching to manual creation tools', async () => {
it('keeps the selected mask when switching to manual creation tools', async () => {
useStore.setState({
selectedMaskIds: ['m1'],
masks: [
@@ -1677,15 +1677,52 @@ describe('CanvasArea', () => {
});
const { rerender } = render(<CanvasArea activeTool="create_polygon" frame={frame} />);
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual([]));
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual(['m1']));
act(() => useStore.getState().setSelectedMaskIds(['m1']));
rerender(<CanvasArea activeTool="create_rectangle" frame={frame} />);
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual([]));
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual(['m1']));
act(() => useStore.getState().setSelectedMaskIds(['m1']));
rerender(<CanvasArea activeTool="create_circle" frame={frame} />);
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual([]));
await waitFor(() => expect(useStore.getState().selectedMaskIds).toEqual(['m1']));
});
it('merges new manual shapes into the selected mask even when separated', () => {
useStore.setState({
selectedMaskIds: ['m1'],
masks: [
{
id: 'm1',
frameId: 'frame-1',
pathData: 'M 20 20 L 80 20 L 80 80 L 20 80 Z',
label: 'Existing',
color: '#06b6d4',
segmentation: [[20, 20, 80, 20, 80, 80, 20, 80]],
area: 3600,
saveStatus: 'saved',
annotationId: '7',
saved: true,
},
],
});
render(<CanvasArea activeTool="create_rectangle" frame={frame} />);
const stage = screen.getByTestId('konva-stage');
fireEvent.mouseDown(stage, { clientX: 180, clientY: 120 });
fireEvent.mouseMove(stage, { clientX: 260, clientY: 200 });
fireEvent.mouseUp(stage, { clientX: 260, clientY: 200 });
expect(useStore.getState().masks).toHaveLength(1);
expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
id: 'm1',
saveStatus: 'dirty',
saved: false,
metadata: expect.objectContaining({ manualMergeShapes: ['矩形'] }),
}));
expect(useStore.getState().masks[0].segmentation).toHaveLength(2);
expect(useStore.getState().masks[0].area).toBeGreaterThan(3600);
expect(useStore.getState().selectedMaskIds).toEqual(['m1']);
});
it('creates a brush mask when a semantic class is selected', () => {
@@ -1816,11 +1853,11 @@ describe('CanvasArea', () => {
}
});
it('creates an independent brush mask even when it touches the selected mask', () => {
it('merges brush strokes into the selected mask even without an active semantic class', () => {
useStore.setState({
activeTemplateId: '2',
activeClass: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, maskId: 1 },
activeClassId: 'c1',
activeClass: null,
activeClassId: null,
selectedMaskIds: ['m1'],
masks: [
{
@@ -1842,23 +1879,17 @@ describe('CanvasArea', () => {
fireEvent.mouseMove(stage, { clientX: 170, clientY: 100 });
fireEvent.mouseUp(stage, { clientX: 210, clientY: 110 });
expect(useStore.getState().masks).toHaveLength(2);
expect(useStore.getState().masks).toHaveLength(1);
expect(useStore.getState().masks[0]).toEqual(expect.objectContaining({
id: 'm1',
label: '胆囊',
color: '#ff0000',
area: 2500,
}));
expect(useStore.getState().masks[1]).toEqual(expect.objectContaining({
label: '胆囊',
color: '#ff0000',
classId: 'c1',
classMaskId: 1,
saveStatus: 'draft',
metadata: expect.objectContaining({ shape: '画笔' }),
metadata: expect.objectContaining({ manualMergeShapes: ['画笔'] }),
}));
expect(useStore.getState().masks[1].area).toBeGreaterThan(1000);
expect(useStore.getState().selectedMaskIds).toEqual([useStore.getState().masks[1].id]);
expect(useStore.getState().masks[0].area).toBeGreaterThan(2500);
expect(useStore.getState().selectedMaskIds).toEqual(['m1']);
});
it('erases from the selected mask with a sampled stroke', () => {

View File

@@ -603,7 +603,7 @@ export function CanvasArea({
const isPaintTool = PAINT_TOOLS.has(effectiveTool);
const isPolygonEditTool = effectiveTool === 'move' || effectiveTool === EDIT_POLYGON_TOOL;
const isManualCreateTool = effectiveTool === POLYGON_TOOL || DRAG_MANUAL_TOOLS.has(effectiveTool);
const canKeepMaskSelection = isPolygonEditTool || isBooleanTool || isPaintTool;
const canKeepMaskSelection = isPolygonEditTool || isBooleanTool || isPaintTool || isManualCreateTool;
const showSelectedMaskVertices = Boolean(selectedMask && (isPolygonEditTool || isPaintTool || isManualCreateTool));
const activePaintSize = effectiveTool === ERASER_TOOL ? eraserSize : brushSize;
const activePaintRadius = Math.max(2, activePaintSize / 2);
@@ -658,8 +658,10 @@ export function CanvasArea({
return {
title: '画笔',
body: activeClass
? '按住并拖动画出连续区域,松开后生成一个新的独立 mask。'
: '先在右侧语义分类树选择类别,然后按住并拖动画出连续区域。',
? '按住并拖动画出连续区域;已有选中 mask 时会并入选中区域,未选中时生成新 mask。'
: selectedMask
? '按住并拖动画出连续区域,松开后并入当前选中 mask。'
: '先在右侧语义分类树选择类别,然后按住并拖动画出连续区域。',
};
}
if (effectiveTool === ERASER_TOOL) {
@@ -920,10 +922,48 @@ export function CanvasArea({
};
};
const mergeGeometryIntoSelectedMask = useCallback((shape: string, geometry: MultiPolygon): Mask | null => {
if (!selectedMask) return null;
const currentSelectedMask = masks.find((mask) => mask.id === selectedMask.id) || selectedMask;
const targetGeometry = maskToMultiPolygon(currentSelectedMask);
if (!targetGeometry) return null;
const resultGeometry = polygonClipping.union(targetGeometry, geometry);
const resultSegmentation = multiPolygonToSegmentation(resultGeometry);
if (resultSegmentation.length === 0) return null;
const metadata = {
...(currentSelectedMask.metadata || {}),
manualMergeShapes: [
...(
Array.isArray(currentSelectedMask.metadata?.manualMergeShapes)
? currentSelectedMask.metadata.manualMergeShapes.filter((item): item is string => typeof item === 'string')
: []
),
shape,
].slice(-12),
};
const nextMask = maskWithSegmentation({
...currentSelectedMask,
metadata,
}, resultSegmentation, {
area: multiPolygonArea(resultGeometry),
hasHoles: multiPolygonHasHoles(resultGeometry),
polygonRingCounts: multiPolygonRingCounts(resultGeometry),
});
setMasks(masks.map((mask) => (mask.id === currentSelectedMask.id ? nextMask : mask)));
setSelectedMaskId(nextMask.id);
setSelectedMaskIds([nextMask.id]);
setGlobalSelectedMaskIds([nextMask.id]);
setSelectedPolygonIndex(0);
setSelectedVertexIndex(null);
return nextMask;
}, [masks, selectedMask, setGlobalSelectedMaskIds, setMasks]);
const createManualMask = useCallback((shape: string, polygon: CanvasPoint[]) => {
if (!frame?.id || polygon.length < 3) return;
const area = polygonArea(polygon);
if (area <= 1) return;
const geometry = polygonsToMultiPolygon([polygon]);
if (geometry && mergeGeometryIntoSelectedMask(shape, geometry)) return;
const templateClass = activeClass || RESERVED_UNCLASSIFIED_CLASS;
const mask: Mask = {
id: `manual-${frame.id}-${shape}-${Date.now()}`,
@@ -949,10 +989,13 @@ export function CanvasArea({
setGlobalSelectedMaskIds([mask.id]);
setSelectedPolygonIndex(0);
setSelectedVertexIndex(null);
}, [activeClass, activeTemplateId, addMask, frame?.id, setGlobalSelectedMaskIds]);
}, [activeClass, activeTemplateId, addMask, frame?.id, mergeGeometryIntoSelectedMask, setGlobalSelectedMaskIds]);
const createManualMaskFromGeometry = useCallback((shape: string, geometry: MultiPolygon): Mask | null => {
if (!frame?.id || !activeClass) return null;
if (!frame?.id) return null;
const mergedMask = mergeGeometryIntoSelectedMask(shape, geometry);
if (mergedMask) return mergedMask;
if (!activeClass) return null;
const segmentation = multiPolygonToSegmentation(geometry);
const polygonRingCounts = multiPolygonRingCounts(geometry);
if (segmentation.length === 0) return null;
@@ -983,7 +1026,7 @@ export function CanvasArea({
};
addMask(mask);
return mask;
}, [activeClass, activeTemplateId, addMask, frame?.id]);
}, [activeClass, activeTemplateId, addMask, frame?.id, mergeGeometryIntoSelectedMask]);
const finishPolygon = useCallback(() => {
if (polygonPoints.length < 3) return;
@@ -1178,7 +1221,7 @@ export function CanvasArea({
if (!strokeGeometry || strokeGeometry.length === 0) return;
if (tool === BRUSH_TOOL) {
if (!activeClass) {
if (!activeClass && !selectedMask) {
setInferenceMessage('请先在右侧语义分类树选择类别,再使用画笔。');
return;
}
@@ -1242,7 +1285,7 @@ export function CanvasArea({
const handleStageMouseDown = (e: any) => {
if (PAINT_TOOLS.has(effectiveTool)) {
const canStart = effectiveTool === BRUSH_TOOL ? Boolean(activeClass) : Boolean(selectedMask);
const canStart = effectiveTool === BRUSH_TOOL ? Boolean(activeClass || selectedMask) : Boolean(selectedMask);
if (!canStart) return;
const pos = stagePoint(e, { clampToImage: false });
if (pos) {