显示新建图形边界点并验证中空画笔

- 让多边形、矩形和圆创建完成后即使仍处于创建工具,也显示已选 mask 的边界顶点。

- 保持创建工具下的边界点为只读提示,不改变继续创建新 mask 的交互。

- 补充 Canvas 回归测试,覆盖矩形、圆、多边形创建后的边界点显示。

- 补充中空画笔回归测试,验证闭合画笔区域保留 hasHoles/polygonRingCounts、evenodd 渲染和内外圈顶点显示。

- 更新 README、AGENTS、前端审计、需求冻结、设计冻结、测试计划和交互状态机文档。
This commit is contained in:
2026-05-04 04:36:53 +08:00
parent 7fc4949677
commit 84895bd9bd
9 changed files with 50 additions and 14 deletions

View File

@@ -1624,6 +1624,8 @@ describe('CanvasArea', () => {
bbox: [120, 80, 140, 120],
}));
expect(useStore.getState().selectedMaskIds).toEqual([useStore.getState().masks[0].id]);
expect(screen.getAllByTestId('konva-circle')
.filter((element) => element.getAttribute('data-fill') === '#ffffff')).toHaveLength(4);
useStore.getState().undoMasks();
expect(useStore.getState().masks).toEqual([]);
@@ -1655,6 +1657,8 @@ describe('CanvasArea', () => {
}));
expect(useStore.getState().masks[0].segmentation?.[0]).toHaveLength(64);
expect(useStore.getState().selectedMaskIds).toEqual([useStore.getState().masks[0].id]);
expect(screen.getAllByTestId('konva-circle')
.filter((element) => element.getAttribute('data-fill') === '#ffffff')).toHaveLength(32);
});
it('clears the selected mask when switching to manual creation tools', async () => {
@@ -1714,6 +1718,34 @@ describe('CanvasArea', () => {
expect(useStore.getState().masks[0].area).toBeGreaterThan(1000);
});
it('preserves hollow brush masks as editable inner rings', () => {
useStore.setState({
activeTemplateId: '2',
activeClass: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, maskId: 1 },
activeClassId: 'c1',
});
render(<CanvasArea activeTool="brush" frame={frame} />);
const stage = screen.getByTestId('konva-stage');
fireEvent.mouseDown(stage, { clientX: 100, clientY: 100 });
fireEvent.mouseMove(stage, { clientX: 240, clientY: 100 });
fireEvent.mouseMove(stage, { clientX: 240, clientY: 240 });
fireEvent.mouseMove(stage, { clientX: 100, clientY: 240 });
fireEvent.mouseMove(stage, { clientX: 100, clientY: 100 });
fireEvent.mouseUp(stage, { clientX: 100, clientY: 100 });
const brushMask = useStore.getState().masks[0];
expect(brushMask.metadata).toEqual(expect.objectContaining({
hasHoles: true,
shape: '画笔',
}));
expect(brushMask.metadata?.polygonRingCounts).toEqual([2]);
expect(brushMask.segmentation).toHaveLength(2);
expect(screen.getByTestId('konva-path')).toHaveAttribute('data-fill-rule', 'evenodd');
expect(screen.getAllByTestId('konva-circle')
.filter((element) => element.getAttribute('data-fill') === '#ffffff').length).toBeGreaterThan(8);
});
it('keeps selected mask vertex markers visible while using brush and eraser', () => {
useStore.setState({
activeTemplateId: '2',
@@ -1915,6 +1947,8 @@ describe('CanvasArea', () => {
}));
expect(screen.queryAllByTestId('konva-circle')
.filter((element) => element.getAttribute('data-fill') === '#facc15')).toHaveLength(0);
expect(screen.queryAllByTestId('konva-circle')
.filter((element) => element.getAttribute('data-fill') === '#ffffff')).toHaveLength(3);
});
it('clears selected masks with Escape so brush can create a new unmerged mask', async () => {

View File

@@ -602,8 +602,9 @@ export function CanvasArea({
const isBooleanTool = BOOLEAN_TOOLS.has(effectiveTool);
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 showSelectedMaskVertices = Boolean(selectedMask && (isPolygonEditTool || isPaintTool));
const showSelectedMaskVertices = Boolean(selectedMask && (isPolygonEditTool || isPaintTool || isManualCreateTool));
const activePaintSize = effectiveTool === ERASER_TOOL ? eraserSize : brushSize;
const activePaintRadius = Math.max(2, activePaintSize / 2);
const setPaintStrokePoints = useCallback((nextPoints: CanvasPoint[]) => {