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..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 stroke | +| `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 | @@ -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/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 8d43355d..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" @@ -461,12 +462,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 +1704,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 +1761,13 @@ 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 = 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/capture.hpp b/src/capture.hpp index 108b0b4e..6fdc595d 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; + /// 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; bool operator==(const Annotation &) const = default; }; diff --git a/src/editor.cpp b/src/editor.cpp index 1c0a9aaf..83cb6922 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,11 +1525,11 @@ 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_]; 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 = @@ -1525,12 +1540,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 +2281,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 +2767,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 +4310,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 +4341,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 +4373,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( @@ -5020,8 +5072,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 +5103,26 @@ 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 { + // 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, + 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 +5311,13 @@ void CaptureEditor::wheelEvent(QWheelEvent *event) { .arg(annotationTextFontName(textFont_)) .arg(QString::fromLatin1(kTextSizeNames.at( static_cast(textSizeIndex_))))); + } else if (tool_ == Tool::Freehand && !layerSelected && + 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 +6809,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..76df55ca 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 @@ -701,6 +702,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..d0a4c471 --- /dev/null +++ b/src/stroke-smoothing.cpp @@ -0,0 +1,187 @@ +/** @fileoverview Implements adjustable release-time pen smoothing. */ +#include "stroke-smoothing.hpp" + +#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; + qsizetype remainingComparisons = maximumSmoothingInput * 16; + + // 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) { + 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 + +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 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. + 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..21f222ad --- /dev/null +++ b/src/stroke-smoothing.hpp @@ -0,0 +1,25 @@ +/** @fileoverview Adjustable post-stroke smoothing over raw pen input. */ +#pragma once + +#include +#include +#include + +namespace stroke { + +inline constexpr int minimumSmoothingLevel = 0; +inline constexpr int maximumSmoothingLevel = 6; +inline constexpr int defaultSmoothingLevel = 3; + +/** + * 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 + * (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..cef6c809 --- /dev/null +++ b/tests/stroke-smoothing-smoke.cpp @@ -0,0 +1,477 @@ +/** @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 +#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) { + // 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: raw 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 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}}; + 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 != raw || 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; + } + + // 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, + (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; + } + // 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(); + } + + 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; + } + 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; +} 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);