Skip to content

橡皮擦:部分擦除、部分套索擦除、按模式独立的尺寸预设,并修复擦除残留 - #389

Merged
alpha-liu-01 merged 8 commits into
alpha-liu-01:mainfrom
misumiio:feature/eraser-partial-mode
Oct 18, 2026
Merged

alpha-liu-01 merged 8 commits into
alpha-liu-01:mainfrom
misumiio:feature/eraser-partial-mode

Conversation

@misumiio

Copy link
Copy Markdown
Contributor

说明:本 PR 叠在 #388 之上

本 PR 的第一个提交就是 #388 的内容(橡皮擦命中半径修复)。建议先合并 #388;如果本 PR 先合并,#388 可以直接关闭。

合并 #388 之后,本 PR 相对 main 的差异只剩橡皮擦新增功能。


功能

新增两个橡皮擦模式,橡皮擦因此有四个模式(子工具栏左侧四个并排按钮,和「笔」的颜色/粗细预设是同一种视觉语言):

模式 行为
整笔擦除 碰到笔画即整条删除(原有)
部分擦除 只擦掉光标圆盘扫过的部分,其余保留
套索擦除 圈出区域,删除与之相交的整条笔画
部分套索擦除 圈出区域,在边界处切断笔画,圈外保留

部分擦除(圆盘)

VectorStroke::subtractEraserSweep() 把橡皮扫过的胶囊体从笔画里减掉。判定半径按笔迹中心线计算,不加半宽——切口是拓扑改变,让圆盘擦到粗笔画边缘就整条断开会很怪。

边界用采样 + 二分收敛,切口落在圆盘真实边界上,而不是最近的采样点。

部分套索擦除(多边形)

VectorStroke::subtractPolygon() 复用同一套片段拼接逻辑。每段的处理抽成了:

subtractWhere(intervalsForSegment)          // 片段拼接/合并/丢弃碎片
survivingIntervalsWhere(p, q, isErasedFn)   // 采样 + 二分细化边界

两种擦除只差一个谓词:圆盘用「到线段距离 < 半径」,套索用「点在多边形内(奇偶填充)」。所以两种套索模式对「圈内」的理解完全一致(和 strokeIntersectsLasso 相同)。

其他改进

  • 撤销粒度:橡皮擦在按下和每次移动时都会执行,之前每次都压一条撤销记录,一次划动就能占满 100 条历史。现在一次「按下→抬起」合并为一条。
    • 部分擦除永远按一次手势合并——因为它是链式替换(第 N 次移动切的是第 N-1 次的片段),简单拼接会记录已不存在的中间态。改为手势开始时快照图层、结束时求差。
    • 整笔擦除的粒度可在 设置 → 工具 → 橡皮擦 选择「每次擦除算一步」或「每一笔算一步」。
  • 尺寸预设按模式独立:整笔擦除和部分擦除各自保存三个预设值与选中项(默认 5/15/40 与 8/15/25),切换模式时整组替换。套索类模式没有尺寸,尺寸行自动隐藏。
  • 模式按钮改为并排:原来是单个循环按钮,看不出有哪些选项、也无法直接回到上一个模式。

修复的 bug

1. 高缩放擦除留下一条细白线(方形)

patchFocusCacheAfterRemoval() 用开启抗锯齿的画笔做 CompositionMode_Clear,会向外渗半个像素;随后重绘又被裁剪在同一条边上,渗出部分既没清干净也没重画 → 留下 1px 背景色细线。

抽出共用的 clearAndRepaintPatch():Clear 时关闭抗锯齿,并把清除与裁剪同步外扩 2 个单位。

2. 套索擦除后残留脏像素

套索删除的是与被圈区域相交的笔画,被删笔画常常伸出圈外;但收尾只按套索范围修补缓存,圈外墨迹永久留在缓存位图里。而笔画已从图层删除,再擦无效,只有滚动等触发缓存重建才恢复。

修补范围改为 套索 ∪ 每个被删笔画的包围盒。

补记:整笔擦除模式此前也有同类问题(只重绘光标圆圈),在同一系列改动里一并修掉。

测试

新增 7 个测试(--test-viewport-unit 42 项、--test-buttons 13 项全过):

  • 橡皮命中半径 = 光标圆圈(粗细笔画分别验证)
  • 快速划动不漏擦、不误擦远处笔画
  • 局部重绘 == 完整重绘(部分擦除)
  • focus 缓存补丁无接缝(分数坐标高缩放;修复前 157 像素差异)
  • 一次擦除 = 一次撤销(三种粒度组合)
  • 套索擦除后重绘与缓存重建逐像素一致(修复前 2673 残留像素)
  • 部分套索在边界切断、圈外不动、一次撤销还原

翻译

四个模式提示、设置里的橡皮擦分组与撤销选项已译为 de / es / fr / pt / zh,并重新生成 .qm。

未包含

  • 切口形状仍为圆头端帽(与 Xournal++ 等一致,笔画模型不模拟真实切口)。
  • 仓库中既有的未翻译条目(非中文各 14 条、中文 88 条,多为 CLI 帮助)未处理。

…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).
Whole-stroke erasing deletes a stroke the moment the disc touches it, which
is the wrong tool for shortening a stroke or opening a gap in a long line.
This adds EraserMode::Partial: the swept capsule is subtracted from each
touched stroke and whatever it missed stays in the layer, so the eraser cuts
where it passes instead of taking the whole stroke.

VectorStroke::subtractEraserSweep() does the clipping. Reach is measured to
the stroke centre line with no half-width term: a cut is a topological
change, so letting a graze of a thick stroke's edge sever it looks wrong.
Segment boundaries are refined by bisection, so the cut lands where the disc
actually ends rather than at the nearest sample.

VectorLayer::replaceStroke() swaps a stroke for its fragments in place.
Appending them would lift a cut stroke above everything drawn after it,
which is visible when overlapping strokes differ in colour.

The mode is stored as EraserMode::Partial between Normal and Lasso, so the
toggle's mode index is the enum value. Saved modes are stamped with
eraserModeVersion: without it, a pre-partial "1" (Lasso) would be read back
as Partial.

ModeToggleButton now cycles an arbitrary number of modes; the two-mode API
is unchanged, so the insert and action toggles are untouched.

Untested on hardware as of this commit - pending manual verification.
Erasing at high zoom left a thin background-coloured line along the edge of
the erased area (a square "ring", since the patch is a rectangle).

The focus-cache patch cleared its rectangle with an *antialiased* painter
(beginFocusPainter enables AA for the redraw), so the clear bled roughly half
a pixel past the rect edge. The redraw that followed was clipped to the exact
same edge, so the bled pixels were neither restored nor repainted - they kept
the cleared (transparent) value and showed as paper colour. The capped patch
had the same structure and only escaped it because a fresh QPainter defaults
to AA off.

Both patches now share clearAndRepaintPatch(), which:
  - clears with AA disabled, so the clear cannot bleed past the rect, and
  - grows the rect (clear AND clip) by CACHE_PATCH_MARGIN, so the two edges
    stay consistent even where they land on fractional device pixels.

Tested by comparing a patched focus cache against a rebuilt one at fractional
coordinates: 157 differing pixels before, none after. The test is kept as a
regression guard (testFocusCachePatchHasNoSeam), alongside a general check
that a region-limited repaint after a partial erase matches a full repaint.
The eraser runs on press and on every pointer move, and each of those calls
pushed its own undo action. A single sweep therefore filled the 100-entry
history, so undoing it took dozens of Ctrl+Z and any genuinely earlier work
had already been evicted. Partial erasing made it worse, since one pass cuts
the same stroke repeatedly.

Eraser undo actions are now buffered while a gesture is open and become one
entry when the pen lifts (begin/commitEraserGesture).

Partial erasing cannot simply concatenate its per-event actions: move N cuts
the fragments move N-1 produced, so the recorded segments reference
intermediate strokes that no longer exist and undoing the batch would restore
a half-cut state. It instead snapshots the layer before the first cut and
diffs against it at commit, which yields exactly the strokes that disappeared
and the ones that are there now.

Whole-stroke erasing keeps its per-event segments — it only ever removes
strokes, so they stay valid — and its granularity is now a setting
(Settings > Tools > Eraser): one step per erase pass (default) or one step
per stroke.

Tests drive a real press/move/release sequence and assert that partial erasing
undoes in one step back to the original stroke, and that the whole-stroke
setting produces one step versus three for the same sweep.
The eraser mode was a single button that cycled whole -> partial -> lasso.
That hides the alternatives, gives no sense of position, and reaching a
previous mode means clicking through the others; at 24 px it was easy to miss
that it was a control at all.

It is now a row of three preset buttons, the same visual language the Pen
subtoolbar uses for its colour and thickness presets: round 24 px buttons on
the subtoolbar background, with an accent ring on the active one. Clicking the
already-active mode does nothing, so the row cannot be toggled into an
"nothing selected" state.

IconPresetButton is the small widget that provides that look; it packs an icon
instead of a colour swatch or a thickness dot, and reuses the _reversed icon
convention for dark mode.

ModeToggleButton goes back to its original two-state form: its N-mode
generalisation existed only for the eraser cycle and would now be unused
surface. The insert- and action-mode toggles are unaffected.

Translations: lupdate picked up the seven new strings (three mode tooltips,
the undo-grouping label, its two options and the explanatory text). They are
translated into de/es/fr/pt/zh and the .qm files rebuilt, so the new UI is not
English-only in a localized build. The pre-existing backlog of untranslated
strings (15 per non-zh language, 89 in zh, mostly CLI help) is untouched.

A button test asserts the three-way picker: exactly one selection at a time,
signals carrying the enum value, no-op on re-click, and external mode changes
moving the ring.
The three size presets were shared by every eraser mode, so switching from
whole-stroke to partial carried the same small radii across - exactly the ones
that are too small to cut a stroke. The two modes want different magnitudes: a
whole-stroke eraser only has to touch ink, while the partial one should span
the stroke it is cutting.

Each disc-based mode now owns three presets plus its own selection, and
switching modes swaps the whole set. Defaults are the values this bar already
shipped with for whole-stroke (5/15/40) and 8/15/25 for partial.

Lasso mode removes everything inside the drawn region and has no size at all,
so the size row and its separator are hidden there instead of offering a
control that changes nothing. cycleSize() is a no-op in that mode. The buttons
keep the last disc-based values while hidden, so currentSize() stays
meaningful and switching back is immediate.

Settings gain a version stamp (3): older layouts had a single shared set,
which is migrated to the whole-stroke slot while the partial slot starts from
its defaults. Per-tab state now carries both modes' sets.

The test drives the real widget: per-mode values and selections survive
round trips, the row hides in lasso, and it snapshots/restores the eraser
QSettings group so a test run cannot wipe a developer's presets.
Two things, both in the lasso eraser.

1. The lasso eraser left stale ink behind. It deletes every stroke that
   *intersects* the drawn region, but repaired the stroke cache over the lasso
   bounds only. A stroke sticking out of the region therefore kept its outside
   ink in the cached pixmap; since the stroke was already gone from the layer it
   could never be erased again, and it only disappeared once something
   unrelated rebuilt the cache (scrolling). The cache is now repaired over the
   union of the lasso and each removed stroke's bounds, in both page and tile
   storage.

2. New EraserMode::LassoPartial: draw a region and cut strokes at its edge,
   keeping everything outside. It reuses the whole-stroke lasso's region test,
   so both lasso modes agree on what the region contains.

   The cutting itself is VectorStroke::subtractPolygon(), which shares the
   fragment stitching with the disc eraser. The per-segment work was factored
   into subtractWhere()/survivingIntervalsWhere(): the disc eraser passes a
   distance predicate, the polygon version a point-in-polygon test, and both
   get the same sample-then-bisect boundary refinement. Cuts therefore land on
   the polygon edge rather than at the nearest sample.

   LassoPartial is appended to the enum so stored modes keep their meaning. It
   has no size (it works on a region), so it hides the size row exactly like
   the whole-stroke lasso; eraserModeUsesDisc()/eraserModeIsLasso() replace the
   scattered comparisons so the new mode cannot be forgotten in one place.

Tests: a lasso erase now repaints identically to a cache rebuild (this failed
with 2673 stale pixels before the fix), the partial lasso splits a crossing
stroke at the region edges while leaving other strokes untouched, its repaint
matches a rebuild, and one undo restores the two originals.

Icons: rope_partial follows the eraser_partial layout - a dashed region ring
above the same broken bar - so the four mode buttons read as one family.
Translations updated for the four mode tooltips (de/es/fr/pt/zh).
Tests cover the behaviours the new modes introduced:
  - a region-limited repaint after a partial erase matches a full repaint
  - patching the focus cache leaves no hairline seam at fractional zoom
  - one erase pass costs one undo entry (partial), and the whole-stroke
    granularity setting yields one entry versus one per stroke
  - lasso erasing repaints the strokes it removed
  - the partial lasso cuts at the region edge and keeps the outside

Translations: the four mode tooltips, the Eraser settings group and its
undo-grouping rows are translated into de/es/fr/pt/zh and the .qm files
rebuilt, so a localized build does not show English in the new controls.
@alpha-liu-01
alpha-liu-01 merged commit 95b78f5 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