修复画笔橡皮擦选区提示与越界绘制

- 画笔和橡皮擦模式下保留当前选中 mask 的顶点提示,并将顶点设为只读,方便确认正在处理的区域。

- 画笔和橡皮擦采样改为图像范围外不落点,离开图像再进入时不会连出跨越边界的笔触。

- 画笔/橡皮擦最终 stroke geometry 按当前帧图像边界裁剪,避免边缘笔触生成图外 polygon。

- 补充 CanvasArea 回归测试,覆盖顶点提示、图外落笔不创建 mask、靠边笔触坐标不越界。

- 更新需求冻结和测试计划文档,记录笔触边界与只读顶点提示行为。
This commit is contained in:
2026-05-04 03:15:47 +08:00
parent 94abad2794
commit 628bce23e0
4 changed files with 132 additions and 20 deletions

View File

@@ -1624,6 +1624,76 @@ describe('CanvasArea', () => {
expect(useStore.getState().masks[0].area).toBeGreaterThan(1000);
});
it('keeps selected mask vertex markers visible while using brush and eraser', () => {
useStore.setState({
activeTemplateId: '2',
activeClass: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, maskId: 1 },
activeClassId: 'c1',
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 selectedVertexHandles = () => screen.getAllByTestId('konva-circle')
.filter((element) => element.getAttribute('data-fill') === '#ffffff');
const { rerender } = render(<CanvasArea activeTool="brush" frame={frame} />);
expect(selectedVertexHandles()).toHaveLength(4);
rerender(<CanvasArea activeTool="eraser" frame={frame} />);
expect(selectedVertexHandles()).toHaveLength(4);
});
it('does not start brush strokes outside the image bounds', () => {
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: 700, clientY: 400 });
fireEvent.mouseMove(stage, { clientX: 720, clientY: 420 });
fireEvent.mouseUp(stage, { clientX: 720, clientY: 420 });
expect(useStore.getState().masks).toHaveLength(0);
});
it('clips brush stroke geometry to the current image bounds', () => {
useStore.setState({
activeTemplateId: '2',
activeClass: { id: 'c1', name: '胆囊', color: '#ff0000', zIndex: 20, maskId: 1 },
activeClassId: 'c1',
brushSize: 40,
});
render(<CanvasArea activeTool="brush" frame={frame} />);
const stage = screen.getByTestId('konva-stage');
fireEvent.mouseDown(stage, { clientX: 630, clientY: 350 });
fireEvent.mouseMove(stage, { clientX: 700, clientY: 420 });
fireEvent.mouseUp(stage, { clientX: 700, clientY: 420 });
expect(useStore.getState().masks).toHaveLength(1);
const coordinates = useStore.getState().masks[0].segmentation?.flat() || [];
for (let index = 0; index < coordinates.length; index += 2) {
expect(coordinates[index]).toBeGreaterThanOrEqual(0);
expect(coordinates[index]).toBeLessThanOrEqual(frame.width);
expect(coordinates[index + 1]).toBeGreaterThanOrEqual(0);
expect(coordinates[index + 1]).toBeLessThanOrEqual(frame.height);
}
});
it('merges a connected brush stroke into the selected mask', () => {
useStore.setState({
activeTemplateId: '2',

View File

@@ -502,6 +502,16 @@ function paintStrokeToGeometry(strokePoints: CanvasPoint[], radius: number): Mul
: polygonClipping.union(firstGeometry, ...restGeometries);
}
function imageBoundsGeometry(width: number, height: number): MultiPolygon | null {
if (width <= 0 || height <= 0) return null;
return polygonsToMultiPolygon([[
{ x: 0, y: 0 },
{ x: width, y: 0 },
{ x: width, y: height },
{ x: 0, y: height },
]]);
}
function geometriesOverlap(first: MultiPolygon, second: MultiPolygon): boolean {
return polygonClipping.intersection(first, second).length > 0;
}
@@ -592,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 showSelectedMaskVertices = Boolean(selectedMask && (isPolygonEditTool || isPaintTool));
const activePaintSize = effectiveTool === ERASER_TOOL ? eraserSize : brushSize;
const activePaintRadius = Math.max(2, activePaintSize / 2);
const setPaintStrokePoints = useCallback((nextPoints: CanvasPoint[]) => {
@@ -852,15 +863,19 @@ export function CanvasArea({
});
};
const stagePoint = (e: any): CanvasPoint | null => {
const stagePoint = (e: any, options: { clampToImage?: boolean } = {}): CanvasPoint | null => {
const stage = e.target.getStage();
const relPos = stage?.getRelativePointerPosition();
if (!relPos) return null;
const imageWidth = frame?.width || image?.naturalWidth || image?.width || stageSize.width;
const imageHeight = frame?.height || image?.naturalHeight || image?.height || stageSize.height;
const shouldClamp = options.clampToImage ?? true;
if (!shouldClamp && (relPos.x < 0 || relPos.y < 0 || relPos.x > imageWidth || relPos.y > imageHeight)) {
return null;
}
return {
x: clamp(relPos.x, 0, imageWidth),
y: clamp(relPos.y, 0, imageHeight),
x: shouldClamp ? clamp(relPos.x, 0, imageWidth) : relPos.x,
y: shouldClamp ? clamp(relPos.y, 0, imageHeight) : relPos.y,
};
};
@@ -955,13 +970,23 @@ export function CanvasArea({
}
if (paintToolRef.current && PAINT_TOOLS.has(effectiveTool)) {
const pos = stagePoint(e);
const pos = stagePoint(e, { clampToImage: false });
const currentStroke = paintStrokeRef.current;
if (!pos) {
lastPaintPointRef.current = null;
return;
}
const previous = lastPaintPointRef.current;
if (!pos || !previous) return;
const radius = Math.max(2, (paintToolRef.current === ERASER_TOOL ? eraserSize : brushSize) / 2);
const minDistance = Math.max(3, radius * 0.55);
if (!previous) {
if (currentStroke.length >= MAX_PAINT_STROKE_POINTS) return;
const nextStroke = [...currentStroke, pos].slice(0, MAX_PAINT_STROKE_POINTS);
lastPaintPointRef.current = pos;
setPaintStrokePoints(nextStroke);
return;
}
if (pointDistance(previous, pos) < minDistance) return;
const currentStroke = paintStrokeRef.current;
if (currentStroke.length >= MAX_PAINT_STROKE_POINTS) return;
const nextStroke = extendStrokePoints(currentStroke, pos, minDistance);
lastPaintPointRef.current = nextStroke[nextStroke.length - 1] || pos;
@@ -1095,8 +1120,15 @@ export function CanvasArea({
const applyPaintStroke = useCallback((tool: string | null, strokePoints: CanvasPoint[]) => {
if (!frame?.id || strokePoints.length === 0) return;
const radius = Math.max(2, (tool === ERASER_TOOL ? eraserSize : brushSize) / 2);
const strokeGeometry = paintStrokeToGeometry(strokePoints, radius);
if (!strokeGeometry) return;
const rawStrokeGeometry = paintStrokeToGeometry(strokePoints, radius);
if (!rawStrokeGeometry) return;
const imageWidth = frame.width || image?.naturalWidth || image?.width || stageSize.width;
const imageHeight = frame.height || image?.naturalHeight || image?.height || stageSize.height;
const imageBounds = imageBoundsGeometry(imageWidth, imageHeight);
const strokeGeometry = imageBounds
? polygonClipping.intersection(rawStrokeGeometry, imageBounds)
: rawStrokeGeometry;
if (!strokeGeometry || strokeGeometry.length === 0) return;
if (tool === BRUSH_TOOL) {
if (!activeClass) {
@@ -1172,16 +1204,24 @@ export function CanvasArea({
deleteMasksById,
eraserSize,
frame?.id,
frame?.height,
frame?.width,
image?.height,
image?.naturalHeight,
image?.naturalWidth,
image?.width,
masks,
selectedMask,
setMasks,
stageSize.height,
stageSize.width,
]);
const handleStageMouseDown = (e: any) => {
if (PAINT_TOOLS.has(effectiveTool)) {
const canStart = effectiveTool === BRUSH_TOOL ? Boolean(activeClass) : Boolean(selectedMask);
if (!canStart) return;
const pos = stagePoint(e);
const pos = stagePoint(e, { clampToImage: false });
if (pos) {
paintToolRef.current = effectiveTool;
lastPaintPointRef.current = pos;
@@ -1211,7 +1251,7 @@ export function CanvasArea({
const handleStageMouseUp = (e: any) => {
if (paintToolRef.current && PAINT_TOOLS.has(effectiveTool)) {
const finalPoint = stagePoint(e);
const finalPoint = stagePoint(e, { clampToImage: false });
const currentStroke = paintStrokeRef.current;
const spacing = Math.max(3, activePaintRadius * 0.55);
const nextStroke = finalPoint
@@ -1813,7 +1853,7 @@ export function CanvasArea({
))}
{/* Polygon vertex editor */}
{isPolygonEditTool && selectedMask && selectedMaskEditableRings.flatMap(({ polygonIndex, points: ringPoints }) => (
{showSelectedMaskVertices && selectedMask && selectedMaskEditableRings.flatMap(({ polygonIndex, points: ringPoints }) => (
ringPoints.map((point, index) => {
const isActiveVertex = selectedPolygonIndex === polygonIndex && selectedVertexIndex === index;
return (
@@ -1825,22 +1865,24 @@ export function CanvasArea({
fill={isActiveVertex ? '#22d3ee' : '#ffffff'}
stroke={selectedMask.color}
strokeWidth={2 / scale}
draggable
onMouseDown={(event: any) => handleVertexDragStart(selectedMask, index, polygonIndex, event)}
onTouchStart={(event: any) => handleVertexDragStart(selectedMask, index, polygonIndex, event)}
onDragStart={(event: any) => handleVertexDragStart(selectedMask, index, polygonIndex, event)}
draggable={isPolygonEditTool}
onMouseDown={isPolygonEditTool ? ((event: any) => handleVertexDragStart(selectedMask, index, polygonIndex, event)) : undefined}
onTouchStart={isPolygonEditTool ? ((event: any) => handleVertexDragStart(selectedMask, index, polygonIndex, event)) : undefined}
onDragStart={isPolygonEditTool ? ((event: any) => handleVertexDragStart(selectedMask, index, polygonIndex, event)) : undefined}
onClick={(event: any) => {
event.cancelBubble = true;
if (!isPolygonEditTool) return;
setSelectedPolygonIndex(polygonIndex);
setSelectedVertexIndex(index);
}}
onTap={(event: any) => {
event.cancelBubble = true;
if (!isPolygonEditTool) return;
setSelectedPolygonIndex(polygonIndex);
setSelectedVertexIndex(index);
}}
onDragMove={(event: any) => handleVertexDrag(selectedMask, index, event, polygonIndex)}
onDragEnd={(event: any) => handleVertexDrag(selectedMask, index, event, polygonIndex)}
onDragMove={isPolygonEditTool ? ((event: any) => handleVertexDrag(selectedMask, index, event, polygonIndex)) : undefined}
onDragEnd={isPolygonEditTool ? ((event: any) => handleVertexDrag(selectedMask, index, event, polygonIndex)) : undefined}
/>
);
})