让新建绘制并入选中遮罩
- 多边形、矩形、圆和画笔在当前已有选中 mask 时,将新几何 union 并入选中 mask,即使区域不重叠也保持为同一个多 polygon mask。 - 无选中 mask 时仍按原新建流程创建并自动选中新 mask;画笔无选区时仍要求右侧语义分类树有选中类别。 - 补充 CanvasArea 回归测试,覆盖创建工具保留选区、分离矩形并入选中 mask、画笔无 active class 时并入选中 mask。 - 更新前端审计、需求冻结、设计冻结、状态机、测试计划和项目指南文档。
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user