Skip to content

修复橡皮擦实际擦除范围大于光标圆圈的问题 - #388

Merged
alpha-liu-01 merged 1 commit into
alpha-liu-01:mainfrom
misumiio:fix/eraser-reach-matches-cursor
Oct 18, 2026
Merged

alpha-liu-01 merged 1 commit into
alpha-liu-01:mainfrom
misumiio:fix/eraser-reach-matches-cursor

Conversation

@misumiio

Copy link
Copy Markdown
Contributor

问题

橡皮擦实际擦除的范围比光标圆圈大:圆圈还没碰到笔画,笔画就被删掉了。

(本 PR 顺带修掉两个相邻问题:快速划动会漏擦,以及擦除后要松开手笔画才整条消失。)

原因

三个独立缺陷叠加:

1. 命中半径与圆圈半径语义不一致(主因)

  • 光标圆圈按 m_eraserSize * zoom 绘制,即"橡皮中心到笔迹中心线的半径"。
  • 而命中判定 VectorStroke::containsPoint() 用的是:
distanceToSegment(point, a, b) < tolerance + baseThickness / 2.0;

即"橡皮圆盘边缘碰到笔迹绘制出的轮廓"。

两者相差半个笔迹宽度。所以圆圈外、R + 半宽 以内的笔画都会被删掉,笔画越粗偏差越明显。

2. 只对离散指针位置做判定

eraseAt() 每个事件只测当前点,两次事件之间没有任何插值。快速划动时若单次位移超过 2 × 命中半径,中间扫过的区域完全不会被检测。

3. 重绘区域覆盖不到被删笔画

删除时 removeStroke() 已经移除了整条笔画,但 update() 只请求重绘光标那个椭圆。包围盒里椭圆之外的像素从未被要求重绘,屏幕上仍是旧画面,直到松开手触发某次全量重绘才刷掉 —— 看起来就像"松手才擦掉整条"。

改动

命中语义改为「实体接触」

source/strokes/VectorStroke.h

// 逐采样点取局部半宽,压感粗细变化的笔画按其局部厚度判定
const qreal reach = radius + (halfWidthAt(i - 1) + halfWidthAt(i)) * 0.5;
if (distanceToSegment(point, points[i-1].pos, points[i].pos) < reach) {
    return true;
}

圆圈代表橡皮自身半径,命中时把笔迹半宽算进来 —— 因为笔迹本身是有宽度的实体。这是「两个实心图形相碰」:

修复前 若直接删掉半宽 本 PR
命中 中心线距 < R + 半宽 中心线距 < R 中心线距 < R + 半宽
圆圈 画 R 画 R 画 R
观感 擦除范围 > 圆圈 擦除范围 < 圆圈 圆盘边缘碰墨迹边缘

注:单纯把 + baseThickness / 2.0 删掉是错的方向。那样橡皮必须压进笔画内部相当距离才生效(粗笔画时能差十几个像素),只是把偏差从偏大换成偏小。正确做法是让两侧语义一致。

扫掠判定(真正解决漏擦)

VectorStroke::intersectsEraserSweep() / VectorLayer::strokesInEraserSweep()

把运动视为从上一采样点到当前点的胶囊体(圆盘沿线段扫过),做线段-线段最近距离判定,并对真正相交的情况单独检测(两端点都远但线段交叉的情形,仅靠端点距离会漏)。

扫掠起点在按下时确立、每次移动后推进、释放时清除,避免跨两次独立接触错误连接。跨页拖动加了 samePage 保护,防止页面局部坐标错位导致误擦。

重绘区域并入被删笔画

source/core/DocumentViewport.cpp

for (const VectorStroke& removed : removedStrokes) {
    dirtyRegion += removedStrokeViewportRect(removed, pe.pageHit.pageIndex).toAlignedRect();
}

包围盒经 pagePosition() 换算到视口坐标并裁剪到视口。edgeless 模式按 tile 原点换算,同样处理。圈一碰到,整条笔画当场消失。

消除常量散落

半径改为单一来源 eraserRadiusDocUnits() / eraserRadiusViewportPixels(),取代散落在绘制与输入路径上的 * m_zoomLevel + 5、+ 10 等多份拷贝。

顺带统一尺寸范围:setEraserSize() clamp 在 5..200,而工具栏提供 2..100,导致小于 5 的预设被静默放大、圆圈画出的半径与用户选的并不一致。现统一引用 DocumentViewport::ERASER_MIN/MAX_SIZE_DOC_UNITS。

测试

新增两个单元测试(--test-viewport):

  • testEraserReachMatchesCursor — 粗笔画(半宽 15)在 34 单位处应命中、40 单位处不应命中;细笔画(半宽 1)在 25 单位处必须不命中(防止细笔画误继承粗笔画的触及范围);dot 笔画同样规则。
  • testEraserSweepClosesSamplingGap — 起止点相距 1000 单位的快速划动必须擦到路径上的笔画、不得擦到 200 单位外的笔画;垂直划动切割水平笔画必须命中(四端点均远,验证交叉检测)。
=== Results: 37 passed, 0 failed ===

行为变化

擦除手感会比以前略"紧":旧行为中细笔画会额外获得半宽加成(本质是 bug)。若需要更宽松的手感,应调大预设尺寸,而不是重新引入半径不一致。

未包含

部分擦除模式(扫到哪擦哪,而非整笔删除)不在本 PR 范围内。它需要给 EraserMode 增加 Partial 并对 VectorStroke 做按采样点的段落切分,属于独立改动。

…ully

The eraser deleted strokes further out than the cursor circle it is aimed
with, and a fast flick skipped strokes it visually crossed. Three separate
causes:

1. Hit testing used `tolerance + baseThickness / 2`, i.e. the eraser disc
   reached the stroke's *outline*, while the cursor circle was drawn with
   `tolerance` alone against the stroke's *centre line*. The two disagreed
   by half the stroke width, so ink outside the circle vanished.

   The rule is now solid-to-solid on both sides: the disc touches the
   stroke's drawn outline, and the stroke contributes its own half-width
   (per-sample, so pressure-tapered strokes are judged by their local
   thickness). Removing the half-width term entirely - the obvious fix -
   makes it worse in the other direction: the circle has to be pushed well
   into the stroke before anything happens.

2. Only the discrete pointer positions were tested, so a swipe faster than
   two events could jump over a stroke without ever touching it. The motion
   is now treated as a capsule swept from the previous sample to the
   current one (segment-to-segment closest approach, plus a proper crossing
   test). This is the part that actually prevents missed erases.

3. The repaint region was the cursor ellipse alone. A removed stroke's ink
   outside that ellipse stayed on screen until some later unrelated repaint,
   which read as "the whole stroke disappears on release" even though the
   removal was immediate. The bounds of every removed stroke are now merged
   into the dirty region, in both paged and edgeless modes.

Also consolidates the eraser radius into a single source of truth
(eraserRadiusDocUnits / eraserRadiusViewportPixels) instead of the ad-hoc
`* m_zoomLevel + 5` and `+ 10` copies scattered through the paint and input
paths, and unifies the size bounds: setEraserSize clamped to 5..200 while
the toolbar offered 2..100, so small presets were silently enlarged and the
cursor drawn at a radius the user had not picked.

Tests cover both directions (thin vs thick strokes, dot strokes, sweep vs
off-path strokes, crossing paths).
@alpha-liu-01
alpha-liu-01 merged commit b6f0092 into alpha-liu-01:main Oct 18, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants