修复画笔橡皮擦选区提示与越界绘制
- 画笔和橡皮擦模式下保留当前选中 mask 的顶点提示,并将顶点设为只读,方便确认正在处理的区域。 - 画笔和橡皮擦采样改为图像范围外不落点,离开图像再进入时不会连出跨越边界的笔触。 - 画笔/橡皮擦最终 stroke geometry 按当前帧图像边界裁剪,避免边缘笔触生成图外 polygon。 - 补充 CanvasArea 回归测试,覆盖顶点提示、图外落笔不创建 mask、靠边笔触坐标不越界。 - 更新需求冻结和测试计划文档,记录笔触边界与只读顶点提示行为。
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user