From 4c8a4695786a15ba45c7b6596510f3b732323d2f Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Mon, 24 Aug 2026 14:22:50 -0500 Subject: [PATCH 1/4] feat(editor): add adjustable pen smoothing --- CMakeLists.txt | 4 + README.md | 4 +- src/capture.cpp | 34 ++- src/capture.hpp | 4 + src/editor.cpp | 173 ++++++++++--- src/editor.hpp | 7 +- src/stroke-smoothing.cpp | 229 +++++++++++++++++ src/stroke-smoothing.hpp | 51 ++++ tests/editor-smoke.cpp | 5 + tests/stroke-smoothing-smoke.cpp | 421 +++++++++++++++++++++++++++++++ tests/stroke-smoothing-smoke.hpp | 8 + 11 files changed, 892 insertions(+), 48 deletions(-) create mode 100644 src/stroke-smoothing.cpp create mode 100644 src/stroke-smoothing.hpp create mode 100644 tests/stroke-smoothing-smoke.cpp create mode 100644 tests/stroke-smoothing-smoke.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 2cdac5e2..c4619339 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -72,6 +72,8 @@ add_library(omasnap-core STATIC src/scroll-inject.hpp src/startup-timing.cpp src/startup-timing.hpp + src/stroke-smoothing.cpp + src/stroke-smoothing.hpp ${PROTOCOL_SOURCES} src/editor.cpp src/editor.hpp @@ -149,6 +151,8 @@ qt_add_executable(omasnap-smoke tests/cut-mapping-smoke.hpp tests/palette-config-smoke.cpp tests/palette-config-smoke.hpp + tests/stroke-smoothing-smoke.cpp + tests/stroke-smoothing-smoke.hpp src/cli-path.cpp src/recent-snaps.cpp src/recent-snaps.hpp diff --git a/README.md b/README.md index 68f1edec..3895481b 100644 --- a/README.md +++ b/README.md @@ -358,7 +358,7 @@ without reaching for the pointer. | `A` | Arrow | | `S` | Spotlight/loupe; press again to cycle ellipse, rectangle, rounded | | `L` | Straight line | -| `F` | Freehand stroke | +| `F` | Freehand pen; medium smoothing by default | | `H` | Highlighter; Snap mode uses a mouse-following I-beam at the nearby text height, then locks the drag straight to that row. Press `H` again (or click the active toolbar button) for Normal freehand mode, where wheel or `Alt`+wheel changes thickness; Snap keeps detected-row height automatic and wheel sets only its off-text fallback | | `I` | Eyedropper in the color popover · sample the image as the custom color | | `C` | Numbered marker | @@ -373,7 +373,7 @@ without reaching for the pointer. | `Shift+B` | Toggle the screenshot card's drop shadow; on by default | | `G` / `Shift+G` | Cycle canvas boundaries forward/backward: Framed, Overflow, Image. Framed auto-grows with the normal frame; Overflow grows only the sides needed by annotations with no frame; Image clips at the original screenshot edge | | `1`–`8` | Set annotation color; `7` is black and `8` is white | -| Wheel | Scale selected layer, magnify the spotlight under the cursor, or change active tool size (`Alt`+wheel: rectangle corner radius or spotlight border); while just viewing a zoomed capture, scroll it like a document | +| Wheel | Scale selected layer, magnify the spotlight under the cursor, or change active tool size (`Alt`+wheel: selected pen smoothing from 0–6, the next pen's smoothing when none is selected, rectangle corner radius, or spotlight border); while just viewing a zoomed capture, scroll it like a document | | `Shift`+wheel | Scroll a zoomed capture sideways (a wide stitch); never changes the zoom | | `Ctrl`+wheel · middle-drag | Zoom about the cursor · pan by dragging | | `+` / `-` / `0` (also with `Ctrl`) | Zoom in / out / fit | diff --git a/src/capture.cpp b/src/capture.cpp index 8d43355d..a30dc103 100644 --- a/src/capture.cpp +++ b/src/capture.cpp @@ -461,12 +461,22 @@ void drawAnnotation(QPainter &painter, const Annotation &annotation) { if (annotation.points.size() < 2) return; QPainterPath stroke(annotation.points.first()); - for (int index = 1; index + 1 < annotation.points.size(); ++index) { - const QPointF midpoint = - (annotation.points.at(index) + annotation.points.at(index + 1)) / 2.0; - stroke.quadTo(annotation.points.at(index), midpoint); + if (annotation.kind == Annotation::Kind::Freehand) { + // Release-time Chaikin points already describe the curve. Connecting + // them directly matches its geometry in preview, hit-testing and export + // instead of applying a second, unrelated quadratic approximation. + for (qsizetype index = 1; index < annotation.points.size(); ++index) + stroke.lineTo(annotation.points.at(index)); + } else { + for (int index = 1; index + 1 < annotation.points.size(); ++index) { + const QPointF midpoint = + (annotation.points.at(index) + annotation.points.at(index + 1)) / + 2.0; + stroke.quadTo(annotation.points.at(index), midpoint); + } + if (annotation.points.size() > 1) + stroke.lineTo(annotation.points.last()); } - stroke.lineTo(annotation.points.last()); painter.setBrush(Qt::NoBrush); if (annotation.kind == Annotation::Kind::Highlighter) { QColor ink = annotation.color; @@ -1693,6 +1703,14 @@ QJsonObject annotationToJson(const Annotation &annotation) { points.push_back(pointArray(point)); object.insert(QStringLiteral("points"), points); } + if (annotation.kind == Annotation::Kind::Freehand) { + QJsonArray rawPoints; + for (const QPointF &point : annotation.rawPoints) + rawPoints.push_back(pointArray(point)); + object.insert(QStringLiteral("rawPoints"), rawPoints); + object.insert(QStringLiteral("smoothingLevel"), + annotation.smoothingLevel); + } if (annotation.kind == Annotation::Kind::Redaction) { object.insert(QStringLiteral("redactionStyle"), annotation.redactionStyle == RedactionStyle::Solid @@ -1742,6 +1760,12 @@ bool annotationFromJson(const QJsonObject &object, Annotation &annotation, annotation.points.clear(); for (const QJsonValue point : object.value(QStringLiteral("points")).toArray()) annotation.points.push_back(pointFromArray(point)); + annotation.rawPoints.clear(); + for (const QJsonValue point : + object.value(QStringLiteral("rawPoints")).toArray()) + annotation.rawPoints.push_back(pointFromArray(point)); + annotation.smoothingLevel = + object.value(QStringLiteral("smoothingLevel")).toInt(0); const QString redactionStyle = object.value(QStringLiteral("redactionStyle")).toString(); annotation.redactionStyle = redactionStyle == QStringLiteral("solid") diff --git a/src/capture.hpp b/src/capture.hpp index 108b0b4e..04a1061b 100644 --- a/src/capture.hpp +++ b/src/capture.hpp @@ -93,6 +93,10 @@ struct Annotation { /// Typeface is a layer property so reopened and duplicated labels keep it. TextFont textFont = TextFont::Neucha; quint64 id = 0; + /// Live-filtered pen geometry retained so smoothing changes never compound. + QVector rawPoints{}; + /// Pen post-stroke smoothing level (0--6); unused by other layer kinds. + int smoothingLevel = 0; bool operator==(const Annotation &) const = default; }; diff --git a/src/editor.cpp b/src/editor.cpp index 1c0a9aaf..a9ab2de7 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -289,11 +289,16 @@ qreal strokeHitTolerance(const Annotation &annotation) { } void translateAnnotation(Annotation &annotation, const QPointF &delta) { annotation.start += delta; - if (hasEndpointHandles(annotation.kind)) + if (hasEndpointHandles(annotation.kind) || + annotation.kind == Annotation::Kind::Freehand) annotation.end += delta; if (isStrokeKind(annotation.kind)) { for (QPointF &point : annotation.points) point += delta; + if (annotation.kind == Annotation::Kind::Freehand) { + for (QPointF &point : annotation.rawPoints) + point += delta; + } } } @@ -1203,14 +1208,19 @@ void CaptureEditor::applyBoxResize(Annotation &annotation, Interaction handle, const qreal scaleY = (original.height() > 0 ? box.height() / original.height() : 1.0) * (flippedY ? -1.0 : 1.0); - for (int index = 0; index < annotation.points.size(); ++index) { - const QPointF relative = - originalAnnotation_.points.at(index) - original.topLeft(); - const QPointF anchor(scaleX < 0 ? box.right() : box.left(), - scaleY < 0 ? box.bottom() : box.top()); - annotation.points[index] = - anchor + QPointF(relative.x() * scaleX, relative.y() * scaleY); - } + const QPointF anchor(scaleX < 0 ? box.right() : box.left(), + scaleY < 0 ? box.bottom() : box.top()); + const auto resizePoints = [&](QVector &resized, + const QVector &source) { + resized.resize(source.size()); + for (qsizetype index = 0; index < source.size(); ++index) { + const QPointF relative = source.at(index) - original.topLeft(); + resized[index] = + anchor + QPointF(relative.x() * scaleX, relative.y() * scaleY); + } + }; + resizePoints(annotation.points, originalAnnotation_.points); + resizePoints(annotation.rawPoints, originalAnnotation_.rawPoints); if (!annotation.points.isEmpty()) { annotation.start = annotation.points.first(); annotation.end = annotation.points.last(); @@ -1310,9 +1320,14 @@ QString CaptureEditor::toolStatus() const { return QStringLiteral("Cut · drag a band to remove it"); case Tool::Highlighter: return highlighterStatus(); + case Tool::Freehand: + return QStringLiteral("Pen · size %1 · smoothing %2/%3 · wheel size · " + "Alt+wheel smoothing") + .arg(size) + .arg(freehandSmoothingLevel_) + .arg(stroke::maximumSmoothingLevel); case Tool::Arrow: case Tool::Line: - case Tool::Freehand: break; } const QString name = tool_ == Tool::Arrow ? QStringLiteral("Arrow") @@ -1510,7 +1525,6 @@ bool CaptureEditor::adjustSelectedAnnotationRing(int step) { // Alt+wheel is the secondary control, and with a layer selected it belongs // to that layer rather than to what the next one will look like. Kinds with // no second setting say so, and the wheel falls through to the armed tool. - beginSelectionAdjust(); if (selectedAnnotation_ < 0 || selectedAnnotation_ >= annotations_.size()) return false; Annotation &annotation = annotations_[selectedAnnotation_]; @@ -1525,12 +1539,38 @@ bool CaptureEditor::adjustSelectedAnnotationRing(int step) { commitPatch({selectedAnnotation_}); return true; } - if (annotation.kind != Annotation::Kind::Spotlight) + if (annotation.kind == Annotation::Kind::Spotlight) { + beginSelectionAdjust(); + annotation.size = + std::clamp(annotation.size + step * 2.0, 0.0, 12.0); + setStatus(spotlightStatus(annotation.spotlightShape, + annotation.magnification, annotation.size)); + commitPatch({selectedAnnotation_}); + return true; + } + if (annotation.kind != Annotation::Kind::Freehand) return false; - annotation.size = std::clamp(annotation.size + step * 2.0, 0.0, 12.0); - setStatus(spotlightStatus(annotation.spotlightShape, - annotation.magnification, annotation.size)); - commitPatch({selectedAnnotation_}); + + beginSelectionAdjust(); + if (annotation.rawPoints.isEmpty()) { + setStatus(QStringLiteral("Pen stroke has no smoothing baseline")); + return true; + } + const int next = std::clamp(annotation.smoothingLevel + step, + stroke::minimumSmoothingLevel, + stroke::maximumSmoothingLevel); + setStatus(QStringLiteral("Pen smoothing %1/%2 · Alt+wheel adjusts · " + "Ctrl+Z undoes") + .arg(next) + .arg(stroke::maximumSmoothingLevel)); + if (next != annotation.smoothingLevel) { + annotation.smoothingLevel = next; + annotation.points = + stroke::smoothFreehand(annotation.rawPoints, annotation.smoothingLevel); + annotation.start = annotation.points.first(); + annotation.end = annotation.points.last(); + commitPatch({selectedAnnotation_}); + } return true; } @@ -2240,8 +2280,11 @@ CaptureEditor::toolbarButtons(QVector *groupDividers, QStringLiteral("Line · L · Shift snaps 45° · Size %1 · Wheel") .arg(qRound(annotationSize_))); add(36, QStringLiteral("tool-freehand"), {}, - QStringLiteral("Freehand · F · Size %1 · Wheel") - .arg(qRound(annotationSize_))); + QStringLiteral("Freehand · F · Size %1 · Wheel · Smoothing %2/%3 · " + "Alt+Wheel") + .arg(qRound(annotationSize_)) + .arg(freehandSmoothingLevel_) + .arg(stroke::maximumSmoothingLevel)); add(36, QStringLiteral("tool-highlighter"), {}, highlighterTooltip()); add(36, QStringLiteral("tool-marker"), {}, QStringLiteral("Number marker · C · Size %1 · Wheel") @@ -2723,6 +2766,8 @@ void CaptureEditor::replayLog() { shift(annotation.end); for (QPointF &point : annotation.points) shift(point); + for (QPointF &point : annotation.rawPoints) + shift(point); } if (band > 0.0) { if (horizontal) @@ -4264,6 +4309,8 @@ void CaptureEditor::mouseMoveEvent(QMouseEvent *event) { if (isStrokeKind(annotation.kind)) { for (QPointF &point : annotation.points) point += annotationDelta; + for (QPointF &point : annotation.rawPoints) + point += annotationDelta; if (!annotation.points.isEmpty()) { annotation.start = annotation.points.first(); annotation.end = annotation.points.last(); @@ -4293,13 +4340,7 @@ void CaptureEditor::mouseMoveEvent(QMouseEvent *event) { const int index = selectedAnnotations_.at(position); Annotation &annotation = annotations_[index]; annotation = originalSelectedAnnotations_.at(position); - annotation.start += delta; - if (hasEndpointHandles(annotation.kind)) - annotation.end += delta; - if (isStrokeKind(annotation.kind)) { - for (QPointF &strokePoint : annotation.points) - strokePoint += delta; - } + translateAnnotation(annotation, delta); } dragChanged_ = true; } else { @@ -4331,12 +4372,22 @@ void CaptureEditor::mouseMoveEvent(QMouseEvent *event) { ? std::max(0.05, (point.y() - originalBounds.top()) / originalBounds.height()) : 1.0; - for (int index = 0; index < annotation.points.size(); ++index) { - const QPointF relative = - originalAnnotation_.points.at(index) - originalBounds.topLeft(); - annotation.points[index] = - originalBounds.topLeft() + - QPointF(relative.x() * scaleX, relative.y() * scaleY); + const auto resizePoints = [&](QVector &resized, + const QVector &source) { + resized.resize(source.size()); + for (qsizetype index = 0; index < source.size(); ++index) { + const QPointF relative = + source.at(index) - originalBounds.topLeft(); + resized[index] = + originalBounds.topLeft() + + QPointF(relative.x() * scaleX, relative.y() * scaleY); + } + }; + resizePoints(annotation.points, originalAnnotation_.points); + resizePoints(annotation.rawPoints, originalAnnotation_.rawPoints); + if (!annotation.points.isEmpty()) { + annotation.start = annotation.points.first(); + annotation.end = annotation.points.last(); } } else if (annotation.kind == Annotation::Kind::Marker) { annotation.size = std::clamp( @@ -4413,9 +4464,12 @@ void CaptureEditor::mouseMoveEvent(QMouseEvent *event) { QPointF point = toUnclampedAnnotationPoint(cursor_); if (tool_ == Tool::Highlighter && highlighterLock_) point.setY(highlighterLock_->centerY); + const QPointF filtered = tool_ == Tool::Freehand + ? freehandInputSmoother_.update(point) + : point; if (freehandPoints_.isEmpty() || - QLineF(freehandPoints_.last(), point).length() >= 1.5) - freehandPoints_.push_back(point); + QLineF(freehandPoints_.last(), filtered).length() >= 1.5) + freehandPoints_.push_back(filtered); } bool overPaletteAnchor = false; bool overCustomAnchor = false; @@ -4869,6 +4923,8 @@ void CaptureEditor::mousePressEvent(QMouseEvent *event) { highlighterLock_ = highlighterPreview_; if (highlighterLock_) strokeStart.setY(highlighterLock_->centerY); + if (tool_ == Tool::Freehand) + freehandInputSmoother_.reset(); freehandPoints_.push_back(strokeStart); if (tool_ == Tool::Highlighter) updatePointerCursor(); @@ -5020,8 +5076,21 @@ void CaptureEditor::mouseReleaseEvent(QMouseEvent *event) { creationConstraintActive_ = false; creationCenteredActive_ = false; if (tool_ == Tool::Freehand || tool_ == Tool::Highlighter) { - if (freehandPoints_.isEmpty() || - QLineF(freehandPoints_.last(), end).length() >= 1.0) + if (tool_ == Tool::Freehand) { + if (freehandPoints_.isEmpty()) + freehandPoints_.push_back(end); + else if (freehandPoints_.size() == 1 || + QLineF(freehandPoints_.last(), end).length() >= 0.001) + freehandPoints_.push_back(end); + else + freehandPoints_.last() = end; + // Live smoothing intentionally trails slow input. The post-stroke pass + // starts from that cleaner trace, but the visible gesture must still + // begin and finish exactly under the pointer. + freehandPoints_.first() = dragStart_; + freehandPoints_.last() = end; + } else if (freehandPoints_.isEmpty() || + QLineF(freehandPoints_.last(), end).length() >= 1.0) freehandPoints_.push_back(end); qreal length = 0; for (int index = 1; index < freehandPoints_.size(); ++index) @@ -5038,11 +5107,24 @@ void CaptureEditor::mouseReleaseEvent(QMouseEvent *event) { annotation.size = highlighter && highlighterLock_ ? highlighterLock_->annotationSize : annotationSize_; - annotation.points = std::move(freehandPoints_); + if (highlighter) { + annotation.points = std::move(freehandPoints_); + } else { + annotation.rawPoints = std::move(freehandPoints_); + annotation.smoothingLevel = freehandSmoothingLevel_; + annotation.points = stroke::smoothFreehand(annotation.rawPoints, + annotation.smoothingLevel); + annotation.start = annotation.points.first(); + annotation.end = annotation.points.last(); + } selectedAnnotation_ = -1; - setStatus(highlighter ? highlighterStatus() - : QStringLiteral("Stroke added · Esc for select " - "mode")); + setStatus( + highlighter + ? highlighterStatus() + : QStringLiteral("Stroke added · smoothing %1/%2 · Esc for " + "select mode") + .arg(annotation.smoothingLevel) + .arg(stroke::maximumSmoothingLevel)); commitAnnotate(std::move(annotation)); } freehandPoints_.clear(); @@ -5231,6 +5313,13 @@ void CaptureEditor::wheelEvent(QWheelEvent *event) { .arg(annotationTextFontName(textFont_)) .arg(QString::fromLatin1(kTextSizeNames.at( static_cast(textSizeIndex_))))); + } else if (tool_ == Tool::Freehand && + modifiers.testFlag(Qt::AltModifier)) { + freehandSmoothingLevel_ = + std::clamp(freehandSmoothingLevel_ + step, + stroke::minimumSmoothingLevel, + stroke::maximumSmoothingLevel); + setStatus(toolStatus()); } else if (tool_ == Tool::Spotlight && event->modifiers().testFlag(Qt::AltModifier)) { // Alt+wheel is the spotlight's secondary control: the ring around the @@ -6722,6 +6811,12 @@ void CaptureEditor::paintEdit(QPainter &painter) { tooltip = QStringLiteral("Ellipse · %1 · Size %2 · Scroll wheel") .arg(fillName(fillShapes_)) .arg(qRound(annotationSize_)); + } else if (tool_ == Tool::Freehand) { + tooltip = QStringLiteral("Size %1 · Scroll wheel · Smoothing %2/%3 " + "· Alt+Wheel") + .arg(qRound(annotationSize_)) + .arg(freehandSmoothingLevel_) + .arg(stroke::maximumSmoothingLevel); } else { tooltip = QStringLiteral("Size %1 · Scroll wheel") .arg(qRound(annotationSize_)); diff --git a/src/editor.hpp b/src/editor.hpp index 9f18fb1d..120932ab 100644 --- a/src/editor.hpp +++ b/src/editor.hpp @@ -6,6 +6,7 @@ #include "overlay-chrome.hpp" #include "palette-config.hpp" #include "recent-snaps.hpp" +#include "stroke-smoothing.hpp" #include #include @@ -572,8 +573,8 @@ class CaptureEditor final : public QWidget { /// with a stroke, the counter or text's own size, magnification for a /// spotlight, extent for the one kind that is all fill. void adjustSelectedAnnotation(int step); - /// Alt+wheel on the selected layer: a spotlight's ring. False when the layer - /// has no second setting to move. + /// Alt+wheel on the selected layer: a spotlight's ring or a pen stroke's + /// smoothing. False when the layer has no second setting to move. bool adjustSelectedAnnotationRing(int step); /// Starts (or extends) the window in which the selection chrome steps back /// so a wheel adjustment can be seen. The handles sit exactly where a @@ -661,6 +662,7 @@ class CaptureEditor final : public QWidget { quint64 highlighterProbeGeneration_ = 0; QFutureWatcher highlighterProbeWatcher_; HighlighterMode highlighterMode_ = HighlighterMode::Snap; + stroke::InputSmoother freehandInputSmoother_; // Cut tool live-drag state. cutDragStart_/cutBandLo_/cutBandHi_ and // liveCut_.orientation are in annotation space (selection-relative logical // px); the source stays untouched while a shaded removal band previews the @@ -701,6 +703,7 @@ class CaptureEditor final : public QWidget { qreal customHue_ = 0.98; int nextMarker_ = 1; qreal annotationSize_ = 4.0; + int freehandSmoothingLevel_ = stroke::defaultSmoothingLevel; bool fillShapes_ = false; qreal cornerRadius_ = 0.0; /// True while a wheel adjustment is in flight; the selection chrome draws diff --git a/src/stroke-smoothing.cpp b/src/stroke-smoothing.cpp new file mode 100644 index 00000000..8cd56f0b --- /dev/null +++ b/src/stroke-smoothing.cpp @@ -0,0 +1,229 @@ +/** @fileoverview Implements live and adjustable release-time pen smoothing. */ +#include "stroke-smoothing.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace stroke { +namespace { + +constexpr qsizetype maximumSmoothingInput = 2048; + +struct SmoothingStage { + int chaikinPasses; + qreal rdpTolerance; +}; + +constexpr SmoothingStage stageForLevel(int level) { + switch (level) { + case 1: + return {1, 0.0}; + case 2: + return {2, 0.0}; + case 3: + return {2, 1.0}; + case 4: + return {2, 2.5}; + case 5: + return {3, 5.0}; + case 6: + return {3, 9.0}; + default: + return {0, 0.0}; + } +} + +qreal pointSegmentDistanceSquared(const QPointF &point, const QPointF &a, + const QPointF &b) { + const QPointF segment = b - a; + const qreal lengthSquared = QPointF::dotProduct(segment, segment); + if (lengthSquared <= std::numeric_limits::epsilon()) { + const QPointF delta = point - a; + return QPointF::dotProduct(delta, delta); + } + const qreal position = + std::clamp(QPointF::dotProduct(point - a, segment) / lengthSquared, 0.0, + 1.0); + const QPointF delta = point - (a + segment * position); + return QPointF::dotProduct(delta, delta); +} + +QVector resampleToBudget(const QVector &points, + qsizetype maximumPoints) { + if (points.size() <= maximumPoints) + return points; + qreal totalLength = 0.0; + for (qsizetype index = 1; index < points.size(); ++index) + totalLength += QLineF(points.at(index - 1), points.at(index)).length(); + if (totalLength <= std::numeric_limits::epsilon()) + return {points.first(), points.last()}; + + QVector sampled; + sampled.reserve(maximumPoints); + sampled.push_back(points.first()); + const qreal spacing = + totalLength / static_cast(maximumPoints - 1); + qreal nextDistance = spacing; + qreal traversed = 0.0; + for (qsizetype index = 1; + index < points.size() && sampled.size() + 1 < maximumPoints; ++index) { + const QPointF &a = points.at(index - 1); + const QPointF &b = points.at(index); + const qreal segmentLength = QLineF(a, b).length(); + if (segmentLength <= std::numeric_limits::epsilon()) + continue; + while (nextDistance <= traversed + segmentLength && + sampled.size() + 1 < maximumPoints) { + const qreal position = (nextDistance - traversed) / segmentLength; + sampled.push_back(a + (b - a) * position); + nextDistance += spacing; + } + traversed += segmentLength; + } + sampled.push_back(points.last()); + return sampled; +} + +QVector rdpSimplify(const QVector &points, + qreal tolerance) { + if (points.size() < 3 || tolerance <= 0.0) + return points; + + struct Span { + qsizetype first; + qsizetype last; + }; + QVector keep(points.size(), false); + keep[0] = true; + keep[points.size() - 1] = true; + QVector pending{{0, points.size() - 1}}; + const qreal toleranceSquared = tolerance * tolerance; + + // An explicit stack avoids recursive call depth on long scribbles. Typical + // pen input is simplified by the first few spans, so the release stays fast. + while (!pending.isEmpty()) { + const Span span = pending.takeLast(); + if (span.last - span.first < 2) + continue; + qreal farthestDistance = -1.0; + qsizetype farthest = span.first; + for (qsizetype index = span.first + 1; index < span.last; ++index) { + const qreal distance = pointSegmentDistanceSquared( + points.at(index), points.at(span.first), points.at(span.last)); + if (distance > farthestDistance) { + farthestDistance = distance; + farthest = index; + } + } + if (farthestDistance <= toleranceSquared) + continue; + keep[farthest] = true; + pending.push_back({span.first, farthest}); + pending.push_back({farthest, span.last}); + } + + QVector simplified; + simplified.reserve(points.size()); + for (qsizetype index = 0; index < points.size(); ++index) { + if (keep.at(index)) + simplified.push_back(points.at(index)); + } + return simplified; +} + +QVector chaikinSmooth(const QVector &points, int passes) { + if (points.size() < 3 || passes <= 0) + return points; + QVector current = points; + for (int pass = 0; pass < passes; ++pass) { + QVector next; + next.reserve(current.size() * 2); + next.push_back(current.first()); + for (qsizetype index = 0; index + 1 < current.size(); ++index) { + const QPointF &a = current.at(index); + const QPointF &b = current.at(index + 1); + next.push_back(a * 0.75 + b * 0.25); + next.push_back(a * 0.25 + b * 0.75); + } + next.push_back(current.last()); + current = std::move(next); + } + return current; +} + +} // namespace + +void InputSmoother::reset() { + historySum_ = {}; + smoothed_ = {}; + historyCount_ = 0; + historyNext_ = 0; + hasSmoothed_ = false; + clock_.invalidate(); +} + +QPointF InputSmoother::update(const QPointF &raw) { + qreal elapsed = 0.0; + if (clock_.isValid()) + elapsed = static_cast(clock_.restart()) / 1000.0; + else + clock_.start(); + return update(raw, elapsed); +} + +QPointF InputSmoother::update(const QPointF &raw, qreal elapsedSeconds) { + if (historyCount_ == historyCapacity) { + historySum_ -= history_.at(static_cast(historyNext_)); + } else { + ++historyCount_; + } + history_[static_cast(historyNext_)] = raw; + historySum_ += raw; + historyNext_ = (historyNext_ + 1) % historyCapacity; + + const QPointF averaged = historySum_ / historyCount_; + const int oldestIndex = + historyCount_ == historyCapacity ? historyNext_ : 0; + const qreal distance = + QLineF(history_.at(static_cast(oldestIndex)), raw).length(); + const qreal duration = + std::clamp(elapsedSeconds * historyCount_, 0.001, 1.0); + const qreal speed = distance / duration; + const qreal normalized = + std::sqrt(std::clamp(speed, 0.01, 500.0) / 500.0); + const qreal alpha = 0.05 + (0.5 - 0.05) * normalized; + + if (!hasSmoothed_) { + smoothed_ = averaged; + hasSmoothed_ = true; + } else { + smoothed_ = averaged * alpha + smoothed_ * (1.0 - alpha); + } + return smoothed_; +} + +QVector smoothFreehand(const QVector &points, int level) { + const int clampedLevel = + std::clamp(level, minimumSmoothingLevel, maximumSmoothingLevel); + if (points.size() < 3 || clampedLevel == minimumSmoothingLevel) + return points; + const SmoothingStage stage = stageForLevel(clampedLevel); + // Bound RDP's quadratic worst case and Chaikin's point expansion before + // either sees an adversarial scribble. Arc-length resampling keeps corners + // independent of the input event rate; ordinary strokes pass through + // byte-for-byte before their configured stage. + const QVector smoothingInput = + resampleToBudget(points, maximumSmoothingInput); + return chaikinSmooth(rdpSimplify(smoothingInput, stage.rdpTolerance), + stage.chaikinPasses); +} + +} // namespace stroke diff --git a/src/stroke-smoothing.hpp b/src/stroke-smoothing.hpp new file mode 100644 index 00000000..dcfd8139 --- /dev/null +++ b/src/stroke-smoothing.hpp @@ -0,0 +1,51 @@ +/** @fileoverview Fast live-input and adjustable post-stroke smoothing. */ +#pragma once + +#include + +#include +#include +#include +#include + +namespace stroke { + +inline constexpr int minimumSmoothingLevel = 0; +inline constexpr int maximumSmoothingLevel = 6; +inline constexpr int defaultSmoothingLevel = 3; + +/** + * Small adaptive live-input filter. The five-point history removes pointer + * chatter while the speed-dependent blend lets quick gestures keep up with + * the hand. reset() starts a new, independent stroke. + */ +class InputSmoother { +public: + void reset(); + [[nodiscard]] QPointF update(const QPointF &raw); + /** Deterministic form used by the smoke suite. */ + [[nodiscard]] QPointF update(const QPointF &raw, qreal elapsedSeconds); + +private: + static constexpr int historyCapacity = 5; + std::array history_{}; + QPointF historySum_; + QPointF smoothed_; + int historyCount_ = 0; + int historyNext_ = 0; + bool hasSmoothed_ = false; + QElapsedTimer clock_; +}; + +/** + * Release-time pen levels: 0 leaves the baseline untouched, 1--2 apply one or + * two anchored Chaikin passes, and 3--6 progressively simplify with + * Ramer-Douglas-Peucker before two or three Chaikin passes. The first and last + * points are always preserved, and strokes shorter than three points + * (including stored dots) pass through untouched. + */ +[[nodiscard]] QVector +smoothFreehand(const QVector &points, + int level = defaultSmoothingLevel); + +} // namespace stroke diff --git a/tests/editor-smoke.cpp b/tests/editor-smoke.cpp index 02eb6f78..1078501e 100644 --- a/tests/editor-smoke.cpp +++ b/tests/editor-smoke.cpp @@ -14,6 +14,7 @@ #include "pin-layout-smoke.hpp" #include "stitch-smoke.hpp" #include "stitch.hpp" +#include "stroke-smoothing-smoke.hpp" #include "pin-lifecycle-smoke.hpp" #include "text-band.hpp" #include "transform-smoke.hpp" @@ -7748,6 +7749,10 @@ int main(int argc, char **argv) { qWarning().noquote() << snapshotError; return 202; } + if (!runStrokeSmoothingSmoke(application, snapshotError)) { + qWarning().noquote() << snapshotError; + return 128; + } if (!runSelectOutsideCanvasSmoke(application, snapshotError)) { qWarning().noquote() << snapshotError; return 106; diff --git a/tests/stroke-smoothing-smoke.cpp b/tests/stroke-smoothing-smoke.cpp new file mode 100644 index 00000000..ac4b1102 --- /dev/null +++ b/tests/stroke-smoothing-smoke.cpp @@ -0,0 +1,421 @@ +/** @fileoverview Exercises adjustable pen smoothing, history and rendering. */ +#include "stroke-smoothing-smoke.hpp" + +#include "capture.hpp" +#include "editor.hpp" +#include "stroke-smoothing.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +bool nearPoint(const QPointF &actual, const QPointF &wanted, + qreal tolerance = 0.001) { + return QLineF(actual, wanted).length() <= tolerance; +} + +qreal polylineLength(const QVector &points) { + qreal length = 0.0; + for (qsizetype index = 1; index < points.size(); ++index) + length += QLineF(points.at(index - 1), points.at(index)).length(); + return length; +} + +QRectF pointBounds(const QVector &points) { + if (points.isEmpty()) + return {}; + qreal left = points.first().x(); + qreal right = left; + qreal top = points.first().y(); + qreal bottom = top; + for (const QPointF &point : points) { + left = std::min(left, point.x()); + right = std::max(right, point.x()); + top = std::min(top, point.y()); + bottom = std::max(bottom, point.y()); + } + return {QPointF(left, top), QPointF(right, bottom)}; +} + +bool hasInkNear(const QImage &image, const QPointF &point, + const QColor &background) { + const int centerX = qRound(point.x()); + const int centerY = qRound(point.y()); + for (int y = centerY - 3; y <= centerY + 3; ++y) { + for (int x = centerX - 3; x <= centerX + 3; ++x) { + if (x >= 0 && y >= 0 && x < image.width() && y < image.height() && + image.pixelColor(x, y) != background) + return true; + } + } + return false; +} + +} // namespace + +bool runStrokeSmoothingSmoke(QApplication &application, QString &error) { + // The online stage uses both a short moving average and a speed-sensitive + // blend. Its first sample is exact, later samples are filtered, and reset + // must not leak the preceding stroke into the next one. + stroke::InputSmoother input; + input.reset(); + const QPointF first = input.update({10, 10}, 0.01); + const QPointF second = input.update({20, 14}, 0.01); + if (first != QPointF(10, 10) || second == QPointF(20, 14) || + second.x() <= first.x() || second.x() >= 20 || second.y() <= 10 || + second.y() >= 14) { + error = QStringLiteral("Adaptive pen input was not smoothed"); + return false; + } + input.reset(); + if (input.update({90, 70}, 0.01) != QPointF(90, 70)) { + error = QStringLiteral("Pen input smoothing leaked between strokes"); + return false; + } + + // Empty, click/dot and two-point strokes are already the simplest possible + // geometry. They must survive every release level byte-for-byte. + const QVector empty; + const QVector dot{{7, 9}}; + const QVector repeatedDot{{7, 9}, {7, 9}}; + const QVector segment{{2, 3}, {40, 30}}; + for (int level = stroke::minimumSmoothingLevel; + level <= stroke::maximumSmoothingLevel; ++level) { + if (stroke::smoothFreehand(empty, level) != empty || + stroke::smoothFreehand(dot, level) != dot || + stroke::smoothFreehand(repeatedDot, level) != repeatedDot || + stroke::smoothFreehand(segment, level) != segment) { + error = QStringLiteral("Pen smoothing changed a short or dot stroke"); + return false; + } + } + + const QVector noisy{{0, 0}, {10, 2}, {20, -2}, {30, 2}, + {40, 20}, {50, 42}, {60, 46}, {70, 41}, + {80, 20}, {90, 2}, {100, -1}, {110, 0}}; + const QVector levelZero = stroke::smoothFreehand(noisy, 0); + const QVector levelOne = stroke::smoothFreehand(noisy, 1); + const QVector levelTwo = stroke::smoothFreehand(noisy, 2); + const QVector smoothed = stroke::smoothFreehand(noisy); + if (stroke::defaultSmoothingLevel != 3 || levelZero != noisy || + stroke::smoothFreehand(noisy, -20) != levelZero || + stroke::smoothFreehand(noisy, 20) != + stroke::smoothFreehand(noisy, stroke::maximumSmoothingLevel) || + levelOne.size() != noisy.size() * 2 || + levelTwo.size() != noisy.size() * 4 || + smoothed != stroke::smoothFreehand(noisy, 3) || + levelOne == levelTwo || levelTwo == smoothed) { + error = QStringLiteral("Pen levels did not match the configured 0--6 stages"); + return false; + } + for (int level = 1; level <= stroke::maximumSmoothingLevel; ++level) { + const QVector result = stroke::smoothFreehand(noisy, level); + if (!nearPoint(result.first(), noisy.first()) || + !nearPoint(result.last(), noisy.last())) { + error = QStringLiteral("A pen smoothing level moved an endpoint"); + return false; + } + } + const auto highest = std::ranges::max_element( + smoothed, {}, [](const QPointF &point) { return point.y(); }); + if (smoothed == noisy || smoothed.size() < 3 || + !nearPoint(smoothed.first(), noisy.first()) || + !nearPoint(smoothed.last(), noisy.last()) || + highest == smoothed.end() || highest->y() < 25.0 || + polylineLength(smoothed) >= polylineLength(noisy)) { + error = QStringLiteral( + "RDP/Chaikin did not smooth jitter while preserving the gesture"); + return false; + } + + // A long, low-amplitude trace collapses before Chaikin expands it. This is + // the common high-sample-rate case and guards against point-count blow-up. + QVector dense; + dense.reserve(50000); + for (int index = 0; index < 50000; ++index) + dense.push_back({index * 0.25, index % 2 == 0 ? -1.0 : 1.0}); + const QVector denseSmoothed = stroke::smoothFreehand(dense, 5); + if (denseSmoothed.size() != 2 || + denseSmoothed.first() != dense.first() || + denseSmoothed.last() != dense.last()) { + error = + QStringLiteral("Dense pen jitter was not simplified before smoothing"); + return false; + } + for (int index = 0; index < dense.size(); ++index) + dense[index].setY(index % 2 == 0 ? -10.0 : 10.0); + const QVector adversarial = stroke::smoothFreehand(dense, 6); + if (adversarial.size() > static_cast(2048 * 8) || + adversarial.first() != dense.first() || + adversarial.last() != dense.last()) { + error = QStringLiteral("A long pen scribble escaped the smoothing budget"); + return false; + } + + const QColor background(QStringLiteral("#182030")); + QImage painted(140, 100, QImage::Format_ARGB32_Premultiplied); + painted.fill(background); + Annotation rendered; + rendered.kind = Annotation::Kind::Freehand; + rendered.color = QColor(QStringLiteral("#ffd60a")); + rendered.size = 6; + rendered.points = smoothed; + rendered.start = smoothed.first(); + rendered.end = smoothed.last(); + for (QPointF &point : rendered.points) + point += QPointF(10, 25); + rendered.start = rendered.points.first(); + rendered.end = rendered.points.last(); + { + QPainter painter(&painted); + painter.setRenderHint(QPainter::Antialiasing, true); + paintAnnotation(painter, rendered); + } + if (!hasInkNear(painted, rendered.start, background) || + !hasInkNear(painted, rendered.end, background)) { + error = QStringLiteral("Smoothed pen endpoints did not render"); + return false; + } + + // Exercise the complete editor path: live input -> release smoothing -> op + // log -> rendering -> stroke hit-test -> box resize -> undo/redo. + CaptureData capture; + capture.monitor.name = QStringLiteral("TEST"); + capture.monitor.geometry = {0, 0, 400, 300}; + capture.monitor.pixelSize = {400, 300}; + capture.monitor.scale = 1.0; + capture.source = QImage(400, 300, QImage::Format_ARGB32_Premultiplied); + capture.source.fill(background); + capture.previewSize = capture.source.size(); + + CaptureEditor editor(capture, CaptureEditor::CaptureMode::File); + editor.setSuppressSnapshots(true); + editor.resize(800, 600); + editor.show(); + application.processEvents(); + // A 400x300 file fits at 1:1 in the editor's (30,68)-(770,542) band. + const QPointF imageOrigin(200, 155); + const QVector raw{{35, 65}, {55, 68}, {75, 62}, {95, 70}, + {115, 90}, {135, 118}, {155, 124}, {175, 116}, + {195, 92}, {215, 72}, {235, 65}}; + QTest::keyClick(&editor, Qt::Key_F); + if (!editor.statusForTest().contains(QStringLiteral("smoothing 3/6")) || + !editor.statusForTest().contains(QStringLiteral("Alt+wheel"))) { + error = QStringLiteral("Arming the pen did not show its smoothing control"); + return false; + } + QTest::mousePress(&editor, Qt::LeftButton, Qt::NoModifier, + (imageOrigin + raw.first()).toPoint()); + for (qsizetype index = 1; index + 1 < raw.size(); ++index) + QTest::mouseMove(&editor, (imageOrigin + raw.at(index)).toPoint(), 1); + QTest::mouseRelease(&editor, Qt::LeftButton, Qt::NoModifier, + (imageOrigin + raw.last()).toPoint()); + application.processEvents(); + if (editor.annotationCountForTest() != 1 || + editor.operationLog().isEmpty() || + editor.operationLog().last().type != Operation::Type::Annotate || + editor.operationLog().last().annotations.size() != 1) { + error = QStringLiteral("A smoothed pen drag did not commit one operation"); + return false; + } + const Annotation committed = editor.operationLog().last().annotations.first(); + if (committed.kind != Annotation::Kind::Freehand || + committed.smoothingLevel != stroke::defaultSmoothingLevel || + committed.rawPoints.size() < 3 || committed.points.size() < 3 || + committed.points == committed.rawPoints || + committed.points != stroke::smoothFreehand(committed.rawPoints, 3) || + !nearPoint(committed.points.first(), raw.first()) || + !nearPoint(committed.points.last(), raw.last()) || + !nearPoint(committed.rawPoints.first(), raw.first()) || + !nearPoint(committed.rawPoints.last(), raw.last()) || + committed.start != committed.points.first() || + committed.end != committed.points.last()) { + error = QStringLiteral( + "Committed pen geometry lost its level, baseline or endpoints"); + return false; + } + const QImage committedOutput = editor.renderCurrentOutput(); + if (!hasInkNear(committedOutput, committed.start, background) || + !hasInkNear(committedOutput, committed.end, background)) { + error = QStringLiteral( + "Committed smoothed pen endpoints were absent from export"); + return false; + } + + QTest::keyClick(&editor, Qt::Key_V); + const QPointF body = committed.points.at(committed.points.size() / 2); + QTest::mouseClick(&editor, Qt::LeftButton, Qt::NoModifier, + (imageOrigin + body).toPoint()); + application.processEvents(); + if (editor.selectedCountForTest() != 1) { + error = QStringLiteral("Smoothed pen geometry was not hit-test selectable"); + return false; + } + + const int beforeSmoothingIndex = editor.operationIndex(); + QWheelEvent smoothWheel(imageOrigin + body, imageOrigin + body, {}, {0, 120}, + Qt::NoButton, Qt::AltModifier, Qt::NoScrollPhase, + false); + QApplication::sendEvent(&editor, &smoothWheel); + application.processEvents(); + if (editor.operationIndex() != beforeSmoothingIndex + 1 || + editor.operationLog().last().type != Operation::Type::Patch || + editor.operationLog().last().annotations.size() != 1) { + error = QStringLiteral("Alt+wheel did not patch the selected pen stroke"); + return false; + } + const Annotation adjusted = + editor.operationLog().last().annotations.constFirst(); + if (adjusted.smoothingLevel != 4 || + adjusted.rawPoints != committed.rawPoints || + adjusted.points != stroke::smoothFreehand(committed.rawPoints, 4) || + !editor.statusForTest().contains(QStringLiteral("smoothing 4/6"))) { + error = QStringLiteral( + "Selected pen smoothing compounded or changed its baseline"); + return false; + } + const QImage adjustedOutput = editor.renderCurrentOutput(); + if (adjustedOutput == committedOutput) { + error = QStringLiteral("Changing pen smoothing did not change its output"); + return false; + } + QTest::keyClick(&editor, Qt::Key_Z, Qt::ControlModifier); + application.processEvents(); + if (editor.renderCurrentOutput() != committedOutput) { + error = QStringLiteral("Undo did not restore the prior pen smoothing"); + return false; + } + QTest::keyClick(&editor, Qt::Key_Y, Qt::ControlModifier); + application.processEvents(); + if (editor.renderCurrentOutput() != adjustedOutput) { + error = QStringLiteral("Redo did not restore adjusted pen smoothing"); + return false; + } + // Leave the baseline level active for the independent resize checks below. + QTest::keyClick(&editor, Qt::Key_Z, Qt::ControlModifier); + application.processEvents(); + + const QImage beforeResize = editor.renderCurrentOutput(); + const QRectF bounds = pointBounds(committed.points); + const QPoint resizeStart = (imageOrigin + bounds.bottomRight()).toPoint(); + QTest::mousePress(&editor, Qt::LeftButton, Qt::NoModifier, resizeStart); + QTest::mouseMove(&editor, resizeStart + QPoint(35, 25), 1); + QTest::mouseRelease(&editor, Qt::LeftButton, Qt::NoModifier, + resizeStart + QPoint(35, 25)); + application.processEvents(); + const QImage afterResize = editor.renderCurrentOutput(); + const Annotation resized = + editor.operationLog().last().annotations.constFirst(); + if (afterResize == beforeResize || editor.operationLog().isEmpty() || + editor.operationLog().last().type != Operation::Type::Patch || + resized.points.size() != committed.points.size() || + resized.rawPoints.size() != committed.rawPoints.size() || + resized.rawPoints == committed.rawPoints) { + error = QStringLiteral( + "A smoothed pen stroke or its baseline did not resize as a layer"); + return false; + } + QTest::keyClick(&editor, Qt::Key_Z, Qt::ControlModifier); + application.processEvents(); + if (editor.renderCurrentOutput() != beforeResize) { + error = QStringLiteral("Undo did not restore the smoothed pen resize"); + return false; + } + QTest::keyClick(&editor, Qt::Key_Y, Qt::ControlModifier); + application.processEvents(); + if (editor.renderCurrentOutput() != afterResize) { + error = QStringLiteral("Redo did not restore the smoothed pen resize"); + return false; + } + + // A click still belongs to the editor's dead zone; smoothing must not turn + // a deselection click into a stray dot layer. + QTest::keyClick(&editor, Qt::Key_F); + const int beforeClick = editor.annotationCountForTest(); + QTest::mouseClick(&editor, Qt::LeftButton, Qt::NoModifier, + (imageOrigin + QPointF(310, 220)).toPoint()); + application.processEvents(); + if (editor.annotationCountForTest() != beforeClick) { + error = QStringLiteral("Pen smoothing turned a click into a stray layer"); + return false; + } + + // With no pen selected, Alt+wheel adjusts the armed default and the next + // stroke records that level rather than mutating an unrelated layer. + { + CaptureEditor defaultsEditor(capture, CaptureEditor::CaptureMode::File); + defaultsEditor.setSuppressSnapshots(true); + defaultsEditor.resize(800, 600); + defaultsEditor.show(); + application.processEvents(); + QTest::keyClick(&defaultsEditor, Qt::Key_F); + QWheelEvent defaultWheel(QPointF(400, 300), QPointF(400, 300), {}, + {0, 120}, Qt::NoButton, Qt::AltModifier, + Qt::NoScrollPhase, false); + QApplication::sendEvent(&defaultsEditor, &defaultWheel); + application.processEvents(); + if (!defaultsEditor.statusForTest().contains( + QStringLiteral("smoothing 4/6"))) { + error = QStringLiteral("Alt+wheel did not change the armed pen default"); + return false; + } + QTest::mousePress(&defaultsEditor, Qt::LeftButton, Qt::NoModifier, + (imageOrigin + raw.first()).toPoint()); + for (qsizetype index = 1; index + 1 < raw.size(); ++index) + QTest::mouseMove(&defaultsEditor, + (imageOrigin + raw.at(index)).toPoint(), 1); + QTest::mouseRelease(&defaultsEditor, Qt::LeftButton, Qt::NoModifier, + (imageOrigin + raw.last()).toPoint()); + application.processEvents(); + if (defaultsEditor.operationLog().isEmpty() || + defaultsEditor.operationLog().last().type != + Operation::Type::Annotate) { + error = QStringLiteral("Adjusted pen default did not create a stroke"); + return false; + } + const Annotation defaultAdjusted = + defaultsEditor.operationLog().last().annotations.constFirst(); + if (defaultAdjusted.smoothingLevel != 4 || + defaultAdjusted.points != + stroke::smoothFreehand(defaultAdjusted.rawPoints, 4)) { + error = QStringLiteral("The next pen stroke ignored its armed smoothing"); + return false; + } + defaultsEditor.close(); + } + + const QTemporaryDir directory; + if (!directory.isValid()) { + error = QStringLiteral("Could not create pen operation-log test directory"); + return false; + } + const OperationLog saved{editor.operationLog(), editor.operationIndex(), 2, + 1, capture.previewSize}; + const QString logPath = directory.filePath(QStringLiteral("pen.json")); + OperationLog reloaded; + if (!saveOperationLog(logPath, saved, error) || + !loadOperationLog(logPath, reloaded, error) || reloaded != saved) { + if (error.isEmpty()) + error = QStringLiteral( + "Smoothed pen points did not round-trip in the op log"); + return false; + } + editor.close(); + return true; +} diff --git a/tests/stroke-smoothing-smoke.hpp b/tests/stroke-smoothing-smoke.hpp new file mode 100644 index 00000000..142b5963 --- /dev/null +++ b/tests/stroke-smoothing-smoke.hpp @@ -0,0 +1,8 @@ +#pragma once + +#include + +class QApplication; + +/** Freehand input, geometry, rendering, selection and history checks. */ +bool runStrokeSmoothingSmoke(QApplication &application, QString &error); From d48ecfde94e61dc737f5595021001367e9b13684 Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Mon, 24 Aug 2026 16:00:00 -0500 Subject: [PATCH 2/4] fix(editor): make pen level zero truly raw --- README.md | 2 +- src/capture.hpp | 2 +- src/editor.cpp | 11 ++---- src/editor.hpp | 1 - src/stroke-smoothing.cpp | 53 +------------------------- src/stroke-smoothing.hpp | 30 +-------------- tests/stroke-smoothing-smoke.cpp | 65 +++++++++++++++++++++----------- 7 files changed, 51 insertions(+), 113 deletions(-) diff --git a/README.md b/README.md index 3895481b..e40be0ea 100644 --- a/README.md +++ b/README.md @@ -358,7 +358,7 @@ without reaching for the pointer. | `A` | Arrow | | `S` | Spotlight/loupe; press again to cycle ellipse, rectangle, rounded | | `L` | Straight line | -| `F` | Freehand pen; medium smoothing by default | +| `F` | Freehand pen; medium `3/6` smoothing by default, while `0/6` preserves the raw pointer path | | `H` | Highlighter; Snap mode uses a mouse-following I-beam at the nearby text height, then locks the drag straight to that row. Press `H` again (or click the active toolbar button) for Normal freehand mode, where wheel or `Alt`+wheel changes thickness; Snap keeps detected-row height automatic and wheel sets only its off-text fallback | | `I` | Eyedropper in the color popover · sample the image as the custom color | | `C` | Numbered marker | diff --git a/src/capture.hpp b/src/capture.hpp index 04a1061b..6fdc595d 100644 --- a/src/capture.hpp +++ b/src/capture.hpp @@ -93,7 +93,7 @@ struct Annotation { /// Typeface is a layer property so reopened and duplicated labels keep it. TextFont textFont = TextFont::Neucha; quint64 id = 0; - /// Live-filtered pen geometry retained so smoothing changes never compound. + /// Raw pointer geometry retained so smoothing changes never compound. QVector rawPoints{}; /// Pen post-stroke smoothing level (0--6); unused by other layer kinds. int smoothingLevel = 0; diff --git a/src/editor.cpp b/src/editor.cpp index a9ab2de7..ef54d244 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -4464,12 +4464,9 @@ void CaptureEditor::mouseMoveEvent(QMouseEvent *event) { QPointF point = toUnclampedAnnotationPoint(cursor_); if (tool_ == Tool::Highlighter && highlighterLock_) point.setY(highlighterLock_->centerY); - const QPointF filtered = tool_ == Tool::Freehand - ? freehandInputSmoother_.update(point) - : point; if (freehandPoints_.isEmpty() || - QLineF(freehandPoints_.last(), filtered).length() >= 1.5) - freehandPoints_.push_back(filtered); + QLineF(freehandPoints_.last(), point).length() >= 1.5) + freehandPoints_.push_back(point); } bool overPaletteAnchor = false; bool overCustomAnchor = false; @@ -4923,8 +4920,6 @@ void CaptureEditor::mousePressEvent(QMouseEvent *event) { highlighterLock_ = highlighterPreview_; if (highlighterLock_) strokeStart.setY(highlighterLock_->centerY); - if (tool_ == Tool::Freehand) - freehandInputSmoother_.reset(); freehandPoints_.push_back(strokeStart); if (tool_ == Tool::Highlighter) updatePointerCursor(); @@ -5110,6 +5105,8 @@ void CaptureEditor::mouseReleaseEvent(QMouseEvent *event) { if (highlighter) { annotation.points = std::move(freehandPoints_); } else { + // Preserve exact endpoints and raw intermediate samples so level zero + // is truly unsmoothed and later level changes never compound. annotation.rawPoints = std::move(freehandPoints_); annotation.smoothingLevel = freehandSmoothingLevel_; annotation.points = stroke::smoothFreehand(annotation.rawPoints, diff --git a/src/editor.hpp b/src/editor.hpp index 120932ab..76df55ca 100644 --- a/src/editor.hpp +++ b/src/editor.hpp @@ -662,7 +662,6 @@ class CaptureEditor final : public QWidget { quint64 highlighterProbeGeneration_ = 0; QFutureWatcher highlighterProbeWatcher_; HighlighterMode highlighterMode_ = HighlighterMode::Snap; - stroke::InputSmoother freehandInputSmoother_; // Cut tool live-drag state. cutDragStart_/cutBandLo_/cutBandHi_ and // liveCut_.orientation are in annotation space (selection-relative logical // px); the source stays untouched while a shaded removal band previews the diff --git a/src/stroke-smoothing.cpp b/src/stroke-smoothing.cpp index 8cd56f0b..308fc754 100644 --- a/src/stroke-smoothing.cpp +++ b/src/stroke-smoothing.cpp @@ -1,9 +1,7 @@ -/** @fileoverview Implements live and adjustable release-time pen smoothing. */ +/** @fileoverview Implements adjustable release-time pen smoothing. */ #include "stroke-smoothing.hpp" #include -#include -#include #include #include @@ -161,55 +159,6 @@ QVector chaikinSmooth(const QVector &points, int passes) { } // namespace -void InputSmoother::reset() { - historySum_ = {}; - smoothed_ = {}; - historyCount_ = 0; - historyNext_ = 0; - hasSmoothed_ = false; - clock_.invalidate(); -} - -QPointF InputSmoother::update(const QPointF &raw) { - qreal elapsed = 0.0; - if (clock_.isValid()) - elapsed = static_cast(clock_.restart()) / 1000.0; - else - clock_.start(); - return update(raw, elapsed); -} - -QPointF InputSmoother::update(const QPointF &raw, qreal elapsedSeconds) { - if (historyCount_ == historyCapacity) { - historySum_ -= history_.at(static_cast(historyNext_)); - } else { - ++historyCount_; - } - history_[static_cast(historyNext_)] = raw; - historySum_ += raw; - historyNext_ = (historyNext_ + 1) % historyCapacity; - - const QPointF averaged = historySum_ / historyCount_; - const int oldestIndex = - historyCount_ == historyCapacity ? historyNext_ : 0; - const qreal distance = - QLineF(history_.at(static_cast(oldestIndex)), raw).length(); - const qreal duration = - std::clamp(elapsedSeconds * historyCount_, 0.001, 1.0); - const qreal speed = distance / duration; - const qreal normalized = - std::sqrt(std::clamp(speed, 0.01, 500.0) / 500.0); - const qreal alpha = 0.05 + (0.5 - 0.05) * normalized; - - if (!hasSmoothed_) { - smoothed_ = averaged; - hasSmoothed_ = true; - } else { - smoothed_ = averaged * alpha + smoothed_ * (1.0 - alpha); - } - return smoothed_; -} - QVector smoothFreehand(const QVector &points, int level) { const int clampedLevel = std::clamp(level, minimumSmoothingLevel, maximumSmoothingLevel); diff --git a/src/stroke-smoothing.hpp b/src/stroke-smoothing.hpp index dcfd8139..21f222ad 100644 --- a/src/stroke-smoothing.hpp +++ b/src/stroke-smoothing.hpp @@ -1,9 +1,6 @@ -/** @fileoverview Fast live-input and adjustable post-stroke smoothing. */ +/** @fileoverview Adjustable post-stroke smoothing over raw pen input. */ #pragma once -#include - -#include #include #include #include @@ -15,30 +12,7 @@ inline constexpr int maximumSmoothingLevel = 6; inline constexpr int defaultSmoothingLevel = 3; /** - * Small adaptive live-input filter. The five-point history removes pointer - * chatter while the speed-dependent blend lets quick gestures keep up with - * the hand. reset() starts a new, independent stroke. - */ -class InputSmoother { -public: - void reset(); - [[nodiscard]] QPointF update(const QPointF &raw); - /** Deterministic form used by the smoke suite. */ - [[nodiscard]] QPointF update(const QPointF &raw, qreal elapsedSeconds); - -private: - static constexpr int historyCapacity = 5; - std::array history_{}; - QPointF historySum_; - QPointF smoothed_; - int historyCount_ = 0; - int historyNext_ = 0; - bool hasSmoothed_ = false; - QElapsedTimer clock_; -}; - -/** - * Release-time pen levels: 0 leaves the baseline untouched, 1--2 apply one or + * Release-time pen levels: 0 preserves the raw pointer path, 1--2 apply one or * two anchored Chaikin passes, and 3--6 progressively simplify with * Ramer-Douglas-Peucker before two or three Chaikin passes. The first and last * points are always preserved, and strokes shorter than three points diff --git a/tests/stroke-smoothing-smoke.cpp b/tests/stroke-smoothing-smoke.cpp index ac4b1102..84d86a86 100644 --- a/tests/stroke-smoothing-smoke.cpp +++ b/tests/stroke-smoothing-smoke.cpp @@ -70,25 +70,6 @@ bool hasInkNear(const QImage &image, const QPointF &point, } // namespace bool runStrokeSmoothingSmoke(QApplication &application, QString &error) { - // The online stage uses both a short moving average and a speed-sensitive - // blend. Its first sample is exact, later samples are filtered, and reset - // must not leak the preceding stroke into the next one. - stroke::InputSmoother input; - input.reset(); - const QPointF first = input.update({10, 10}, 0.01); - const QPointF second = input.update({20, 14}, 0.01); - if (first != QPointF(10, 10) || second == QPointF(20, 14) || - second.x() <= first.x() || second.x() >= 20 || second.y() <= 10 || - second.y() >= 14) { - error = QStringLiteral("Adaptive pen input was not smoothed"); - return false; - } - input.reset(); - if (input.update({90, 70}, 0.01) != QPointF(90, 70)) { - error = QStringLiteral("Pen input smoothing leaked between strokes"); - return false; - } - // Empty, click/dot and two-point strokes are already the simplest possible // geometry. They must survive every release level byte-for-byte. const QVector empty; @@ -193,7 +174,7 @@ bool runStrokeSmoothingSmoke(QApplication &application, QString &error) { return false; } - // Exercise the complete editor path: live input -> release smoothing -> op + // Exercise the complete editor path: raw input -> release smoothing -> op // log -> rendering -> stroke hit-test -> box resize -> undo/redo. CaptureData capture; capture.monitor.name = QStringLiteral("TEST"); @@ -209,8 +190,9 @@ bool runStrokeSmoothingSmoke(QApplication &application, QString &error) { editor.resize(800, 600); editor.show(); application.processEvents(); - // A 400x300 file fits at 1:1 in the editor's (30,68)-(770,542) band. - const QPointF imageOrigin(200, 155); + // A 400x300 file fits at 1:1 in the editor's content band; the measured + // origin keeps the drag coordinates exact whatever the chrome height is. + const QPointF imageOrigin = editor.editImageRectForTest().topLeft(); const QVector raw{{35, 65}, {55, 68}, {75, 62}, {95, 70}, {115, 90}, {135, 118}, {155, 124}, {175, 116}, {195, 92}, {215, 72}, {235, 65}}; @@ -237,7 +219,7 @@ bool runStrokeSmoothingSmoke(QApplication &application, QString &error) { const Annotation committed = editor.operationLog().last().annotations.first(); if (committed.kind != Annotation::Kind::Freehand || committed.smoothingLevel != stroke::defaultSmoothingLevel || - committed.rawPoints.size() < 3 || committed.points.size() < 3 || + committed.rawPoints != raw || committed.points.size() < 3 || committed.points == committed.rawPoints || committed.points != stroke::smoothFreehand(committed.rawPoints, 3) || !nearPoint(committed.points.first(), raw.first()) || @@ -258,6 +240,43 @@ bool runStrokeSmoothingSmoke(QApplication &application, QString &error) { return false; } + // Level zero must keep the exact samples delivered to the editor. The + // default starts at three, so explicitly wheel it down before drawing. + { + CaptureEditor rawEditor(capture, CaptureEditor::CaptureMode::File); + rawEditor.setSuppressSnapshots(true); + rawEditor.resize(800, 600); + rawEditor.show(); + application.processEvents(); + QTest::keyClick(&rawEditor, Qt::Key_F); + for (int level = stroke::defaultSmoothingLevel; + level > stroke::minimumSmoothingLevel; --level) { + QWheelEvent wheel(QPointF(400, 300), QPointF(400, 300), {}, {0, -120}, + Qt::NoButton, Qt::AltModifier, Qt::NoScrollPhase, + false); + QApplication::sendEvent(&rawEditor, &wheel); + } + QTest::mousePress(&rawEditor, Qt::LeftButton, Qt::NoModifier, + (imageOrigin + raw.first()).toPoint()); + for (qsizetype index = 1; index + 1 < raw.size(); ++index) + QTest::mouseMove(&rawEditor, + (imageOrigin + raw.at(index)).toPoint(), 1); + QTest::mouseRelease(&rawEditor, Qt::LeftButton, Qt::NoModifier, + (imageOrigin + raw.last()).toPoint()); + application.processEvents(); + if (rawEditor.operationLog().isEmpty()) { + error = QStringLiteral("Raw pen stroke did not commit"); + return false; + } + const Annotation rawStroke = + rawEditor.operationLog().last().annotations.constFirst(); + if (rawStroke.smoothingLevel != stroke::minimumSmoothingLevel || + rawStroke.rawPoints != raw || rawStroke.points != rawStroke.rawPoints) { + error = QStringLiteral("Pen level zero did not preserve raw input"); + return false; + } + } + QTest::keyClick(&editor, Qt::Key_V); const QPointF body = committed.points.at(committed.points.size() / 2); QTest::mouseClick(&editor, Qt::LeftButton, Qt::NoModifier, From 0dc019d54abace76ac180ba884350c94accda444 Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Sun, 6 Sep 2026 18:28:21 -0500 Subject: [PATCH 3/4] fix(editor): keep secondary wheel controls scoped to selection --- src/editor.cpp | 3 ++- tests/stroke-smoothing-smoke.cpp | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/editor.cpp b/src/editor.cpp index ef54d244..83cb6922 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -1529,6 +1529,7 @@ bool CaptureEditor::adjustSelectedAnnotationRing(int step) { return false; Annotation &annotation = annotations_[selectedAnnotation_]; if (annotation.kind == Annotation::Kind::Rectangle) { + beginSelectionAdjust(); // The selected rectangle's own corners, undoably; the armed tool's // default radius stays what it was. annotation.cornerRadius = @@ -5310,7 +5311,7 @@ void CaptureEditor::wheelEvent(QWheelEvent *event) { .arg(annotationTextFontName(textFont_)) .arg(QString::fromLatin1(kTextSizeNames.at( static_cast(textSizeIndex_))))); - } else if (tool_ == Tool::Freehand && + } else if (tool_ == Tool::Freehand && !layerSelected && modifiers.testFlag(Qt::AltModifier)) { freehandSmoothingLevel_ = std::clamp(freehandSmoothingLevel_ + step, diff --git a/tests/stroke-smoothing-smoke.cpp b/tests/stroke-smoothing-smoke.cpp index 84d86a86..58d42770 100644 --- a/tests/stroke-smoothing-smoke.cpp +++ b/tests/stroke-smoothing-smoke.cpp @@ -416,6 +416,29 @@ bool runStrokeSmoothingSmoke(QApplication &application, QString &error) { error = QStringLiteral("The next pen stroke ignored its armed smoothing"); return false; } + // A selected line has no secondary control. Arming F must not allow + // Alt+wheel to silently change the next stroke's smoothing. + QTest::keyClick(&defaultsEditor, Qt::Key_L); + const QPoint lineStart = (imageOrigin + QPointF(40, 180)).toPoint(); + const QPoint lineEnd = (imageOrigin + QPointF(180, 180)).toPoint(); + QTest::mousePress(&defaultsEditor, Qt::LeftButton, Qt::NoModifier, lineStart); + QTest::mouseMove(&defaultsEditor, lineEnd, 1); + QTest::mouseRelease(&defaultsEditor, Qt::LeftButton, Qt::NoModifier, lineEnd); + QTest::keyClick(&defaultsEditor, Qt::Key_V); + QTest::mouseClick(&defaultsEditor, Qt::LeftButton, Qt::NoModifier, + (lineStart + lineEnd) / 2); + QTest::keyClick(&defaultsEditor, Qt::Key_F); + if (defaultsEditor.selectedCountForTest() != 1) { + error = QStringLiteral("Pen default regression fixture lost selection"); + return false; + } + QApplication::sendEvent(&defaultsEditor, &defaultWheel); + QTest::keyClick(&defaultsEditor, Qt::Key_V); + QTest::keyClick(&defaultsEditor, Qt::Key_F); + if (!defaultsEditor.statusForTest().contains(QStringLiteral("smoothing 4/6"))) { + error = QStringLiteral("Selected line changed the armed pen smoothing"); + return false; + } defaultsEditor.close(); } From 452c97d95c92f9d2b56c3fce326633882038d513 Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Sun, 6 Sep 2026 19:34:15 -0500 Subject: [PATCH 4/4] fix(pen): bound simplification work and validate loaded levels --- docs/threading.md | 8 ++++++++ src/capture.cpp | 6 ++++-- src/stroke-smoothing.cpp | 15 ++++++++++++--- tests/stroke-smoothing-smoke.cpp | 14 ++++++++++++++ 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/docs/threading.md b/docs/threading.md index cc2095d2..7a790132 100644 --- a/docs/threading.md +++ b/docs/threading.md @@ -130,3 +130,11 @@ earlier in the same call. See also [editing-model.md](editing-model.md) for what state a background render is allowed to read, and [dependencies.md](dependencies.md) for the processes (`tesseract`, `wl-copy`/`wl-paste`, `hyprctl`) these workers spawn. + +## Pen smoothing budget + +Release-time smoothing bounds the iterative RDP pass to 32,768 point-to-segment +comparisons over at most 2,048 samples. When that budget runs out, unexamined +spans retain their samples; no quadratic scan continues on the input thread. +At most three Chaikin passes then produce 16,384 points. The initial arc-length +resampling remains linear in the raw stroke length. diff --git a/src/capture.cpp b/src/capture.cpp index a30dc103..3b7258f3 100644 --- a/src/capture.cpp +++ b/src/capture.cpp @@ -1,5 +1,6 @@ /** @fileoverview Captures, renders, saves, and shares screenshots. */ #include "capture.hpp" +#include "stroke-smoothing.hpp" #include "output-config.hpp" #include "startup-timing.hpp" @@ -1764,8 +1765,9 @@ bool annotationFromJson(const QJsonObject &object, Annotation &annotation, for (const QJsonValue point : object.value(QStringLiteral("rawPoints")).toArray()) annotation.rawPoints.push_back(pointFromArray(point)); - annotation.smoothingLevel = - object.value(QStringLiteral("smoothingLevel")).toInt(0); + annotation.smoothingLevel = std::clamp( + object.value(QStringLiteral("smoothingLevel")).toInt(0), + stroke::minimumSmoothingLevel, stroke::maximumSmoothingLevel); const QString redactionStyle = object.value(QStringLiteral("redactionStyle")).toString(); annotation.redactionStyle = redactionStyle == QStringLiteral("solid") diff --git a/src/stroke-smoothing.cpp b/src/stroke-smoothing.cpp index 308fc754..d0a4c471 100644 --- a/src/stroke-smoothing.cpp +++ b/src/stroke-smoothing.cpp @@ -104,13 +104,22 @@ QVector rdpSimplify(const QVector &points, keep[points.size() - 1] = true; QVector pending{{0, points.size() - 1}}; const qreal toleranceSquared = tolerance * tolerance; + qsizetype remainingComparisons = maximumSmoothingInput * 16; - // An explicit stack avoids recursive call depth on long scribbles. Typical - // pen input is simplified by the first few spans, so the release stays fast. + // Bound work as well as recursion: a zig-zag can make RDP quadratic. + // Once the comparison budget is spent, retain the unexamined spans rather + // than approximating them with chords. Chaikin still smooths those points. while (!pending.isEmpty()) { const Span span = pending.takeLast(); if (span.last - span.first < 2) continue; + const qsizetype comparisons = span.last - span.first - 1; + if (comparisons > remainingComparisons) { + for (qsizetype index = span.first + 1; index < span.last; ++index) + keep[index] = true; + continue; + } + remainingComparisons -= comparisons; qreal farthestDistance = -1.0; qsizetype farthest = span.first; for (qsizetype index = span.first + 1; index < span.last; ++index) { @@ -165,7 +174,7 @@ QVector smoothFreehand(const QVector &points, int level) { if (points.size() < 3 || clampedLevel == minimumSmoothingLevel) return points; const SmoothingStage stage = stageForLevel(clampedLevel); - // Bound RDP's quadratic worst case and Chaikin's point expansion before + // Bound the input and Chaikin's point expansion before // either sees an adversarial scribble. Arc-length resampling keeps corners // independent of the input event rate; ordinary strokes pass through // byte-for-byte before their configured stage. diff --git a/tests/stroke-smoothing-smoke.cpp b/tests/stroke-smoothing-smoke.cpp index 58d42770..cef6c809 100644 --- a/tests/stroke-smoothing-smoke.cpp +++ b/tests/stroke-smoothing-smoke.cpp @@ -22,6 +22,7 @@ #include #include +#include namespace { @@ -458,6 +459,19 @@ bool runStrokeSmoothingSmoke(QApplication &application, QString &error) { "Smoothed pen points did not round-trip in the op log"); return false; } + for (const int level : {std::numeric_limits::min(), + std::numeric_limits::max()}) { + OperationLog invalid = saved; + invalid.ops.last().annotations.first().smoothingLevel = level; + if (!saveOperationLog(logPath, invalid, error) || + !loadOperationLog(logPath, reloaded, error) || + reloaded.ops.last().annotations.first().smoothingLevel != + std::clamp(level, stroke::minimumSmoothingLevel, + stroke::maximumSmoothingLevel)) { + error = QStringLiteral("Loaded pen smoothing escaped its valid range"); + return false; + } + } editor.close(); return true; }