Repository navigation
橡皮擦:部分擦除、部分套索擦除、按模式独立的尺寸预设,并修复擦除残留 - #389
Merged
alpha-liu-01 merged 8 commits intoOct 18, 2026
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
说明:本 PR 叠在 #388 之上
本 PR 的第一个提交就是 #388 的内容(橡皮擦命中半径修复)。建议先合并 #388;如果本 PR 先合并,#388 可以直接关闭。
合并 #388 之后,本 PR 相对 main 的差异只剩橡皮擦新增功能。
功能
新增两个橡皮擦模式,橡皮擦因此有四个模式(子工具栏左侧四个并排按钮,和「笔」的颜色/粗细预设是同一种视觉语言):
部分擦除(圆盘)
VectorStroke::subtractEraserSweep()把橡皮扫过的胶囊体从笔画里减掉。判定半径按笔迹中心线计算,不加半宽——切口是拓扑改变,让圆盘擦到粗笔画边缘就整条断开会很怪。边界用采样 + 二分收敛,切口落在圆盘真实边界上,而不是最近的采样点。
部分套索擦除(多边形)
VectorStroke::subtractPolygon()复用同一套片段拼接逻辑。每段的处理抽成了:两种擦除只差一个谓词:圆盘用「到线段距离 < 半径」,套索用「点在多边形内(奇偶填充)」。所以两种套索模式对「圈内」的理解完全一致(和
strokeIntersectsLasso相同)。其他改进
修复的 bug
1. 高缩放擦除留下一条细白线(方形)
patchFocusCacheAfterRemoval()用开启抗锯齿的画笔做CompositionMode_Clear,会向外渗半个像素;随后重绘又被裁剪在同一条边上,渗出部分既没清干净也没重画 → 留下 1px 背景色细线。抽出共用的
clearAndRepaintPatch():Clear 时关闭抗锯齿,并把清除与裁剪同步外扩 2 个单位。2. 套索擦除后残留脏像素
套索删除的是与被圈区域相交的笔画,被删笔画常常伸出圈外;但收尾只按套索范围修补缓存,圈外墨迹永久留在缓存位图里。而笔画已从图层删除,再擦无效,只有滚动等触发缓存重建才恢复。
修补范围改为 套索 ∪ 每个被删笔画的包围盒。
测试
新增 7 个测试(
--test-viewport-unit42 项、--test-buttons13 项全过):翻译
四个模式提示、设置里的橡皮擦分组与撤销选项已译为 de / es / fr / pt / zh,并重新生成
.qm。未包含