diff --git a/cpp/ActiveStrokeRenderer.h b/cpp/ActiveStrokeRenderer.h index 1471a0b..70c3473 100644 --- a/cpp/ActiveStrokeRenderer.h +++ b/cpp/ActiveStrokeRenderer.h @@ -6,7 +6,8 @@ #include #include #include -#include "SkiaDrawingEngine.h" // For Point struct +#include "DrawingTypes.h" +#include namespace nativedrawing { diff --git a/cpp/DrawingHistory.cpp b/cpp/DrawingHistory.cpp new file mode 100644 index 0000000..90e6434 --- /dev/null +++ b/cpp/DrawingHistory.cpp @@ -0,0 +1,170 @@ +#include "DrawingHistory.h" +#include "PathRenderer.h" +#include +#include + +namespace nativedrawing { + +void commitStrokeDelta( + std::vector& undoStack, + std::vector& redoStack, + StrokeDelta&& delta, + size_t maxHistoryEntries +) { + redoStack.clear(); + undoStack.push_back(std::move(delta)); + while (undoStack.size() > maxHistoryEntries) { + undoStack.erase(undoStack.begin()); + } +} + +void appendPixelEraseCircleToDelta( + std::vector& pendingPixelEraseEntries, + size_t strokeIndex, + const EraserCircle& circle +) { + for (auto& entry : pendingPixelEraseEntries) { + if (entry.strokeIndex == strokeIndex) { + entry.addedCircles.push_back(circle); + return; + } + } + + StrokeDelta::PixelEraseEntry entry; + entry.strokeIndex = strokeIndex; + entry.addedCircles.push_back(circle); + pendingPixelEraseEntries.push_back(std::move(entry)); +} + +void applyStrokeDelta( + const StrokeDelta& delta, + std::vector& strokes, + std::vector& eraserCircles, + PathRenderer& pathRenderer +) { + switch (delta.kind) { + case StrokeDelta::Kind::AddStrokes: { + for (const auto& stroke : delta.addedStrokes) { + strokes.push_back(stroke); + } + break; + } + case StrokeDelta::Kind::RemoveStrokes: { + for (auto it = delta.removedStrokes.rbegin(); it != delta.removedStrokes.rend(); ++it) { + if (it->first < strokes.size()) { + strokes.erase(strokes.begin() + static_cast(it->first)); + } + } + break; + } + case StrokeDelta::Kind::PixelErase: { + for (const auto& entry : delta.pixelEraseEntries) { + if (entry.strokeIndex >= strokes.size()) continue; + auto& target = strokes[entry.strokeIndex].erasedBy; + for (const auto& circle : entry.addedCircles) { + target.push_back(circle); + } + strokes[entry.strokeIndex].cachedEraserCount = 0; + } + break; + } + case StrokeDelta::Kind::MoveStrokes: { + for (size_t index : delta.moveIndices) { + if (index >= strokes.size()) continue; + auto& stroke = strokes[index]; + for (auto& point : stroke.points) { + point.x += delta.moveDx; + point.y += delta.moveDy; + } + for (auto& circle : stroke.erasedBy) { + circle.x += delta.moveDx; + circle.y += delta.moveDy; + } + pathRenderer.smoothPath(stroke.points, stroke.path); + stroke.cachedEraserCount = 0; + } + break; + } + case StrokeDelta::Kind::ReplaceStrokes: { + for (const auto& [index, stroke] : delta.afterStrokes) { + if (index < strokes.size()) { + strokes[index] = stroke; + } + } + break; + } + case StrokeDelta::Kind::Clear: { + strokes.clear(); + eraserCircles.clear(); + break; + } + } +} + +void revertStrokeDelta( + const StrokeDelta& delta, + std::vector& strokes, + std::vector& eraserCircles, + PathRenderer& pathRenderer +) { + switch (delta.kind) { + case StrokeDelta::Kind::AddStrokes: { + for (size_t i = 0; i < delta.addedStrokes.size(); ++i) { + if (!strokes.empty()) { + strokes.pop_back(); + } + } + break; + } + case StrokeDelta::Kind::RemoveStrokes: { + for (const auto& [index, stroke] : delta.removedStrokes) { + size_t clamped = std::min(index, strokes.size()); + strokes.insert(strokes.begin() + static_cast(clamped), stroke); + } + break; + } + case StrokeDelta::Kind::PixelErase: { + for (const auto& entry : delta.pixelEraseEntries) { + if (entry.strokeIndex >= strokes.size()) continue; + auto& target = strokes[entry.strokeIndex].erasedBy; + for (size_t i = 0; i < entry.addedCircles.size() && !target.empty(); ++i) { + target.pop_back(); + } + strokes[entry.strokeIndex].cachedEraserCount = 0; + } + break; + } + case StrokeDelta::Kind::MoveStrokes: { + for (size_t index : delta.moveIndices) { + if (index >= strokes.size()) continue; + auto& stroke = strokes[index]; + for (auto& point : stroke.points) { + point.x -= delta.moveDx; + point.y -= delta.moveDy; + } + for (auto& circle : stroke.erasedBy) { + circle.x -= delta.moveDx; + circle.y -= delta.moveDy; + } + pathRenderer.smoothPath(stroke.points, stroke.path); + stroke.cachedEraserCount = 0; + } + break; + } + case StrokeDelta::Kind::ReplaceStrokes: { + for (const auto& [index, stroke] : delta.beforeStrokes) { + if (index < strokes.size()) { + strokes[index] = stroke; + } + } + break; + } + case StrokeDelta::Kind::Clear: { + strokes = delta.clearedStrokes; + eraserCircles = delta.clearedEraserCircles; + break; + } + } +} + +} // namespace nativedrawing diff --git a/cpp/DrawingHistory.h b/cpp/DrawingHistory.h new file mode 100644 index 0000000..b9ede97 --- /dev/null +++ b/cpp/DrawingHistory.h @@ -0,0 +1,37 @@ +#pragma once + +#include "DrawingTypes.h" +#include + +namespace nativedrawing { + +class PathRenderer; + +void commitStrokeDelta( + std::vector& undoStack, + std::vector& redoStack, + StrokeDelta&& delta, + size_t maxHistoryEntries +); + +void appendPixelEraseCircleToDelta( + std::vector& pendingPixelEraseEntries, + size_t strokeIndex, + const EraserCircle& circle +); + +void applyStrokeDelta( + const StrokeDelta& delta, + std::vector& strokes, + std::vector& eraserCircles, + PathRenderer& pathRenderer +); + +void revertStrokeDelta( + const StrokeDelta& delta, + std::vector& strokes, + std::vector& eraserCircles, + PathRenderer& pathRenderer +); + +} // namespace nativedrawing diff --git a/cpp/DrawingSelection.cpp b/cpp/DrawingSelection.cpp index 30550d3..7e3f20c 100644 --- a/cpp/DrawingSelection.cpp +++ b/cpp/DrawingSelection.cpp @@ -1,4 +1,5 @@ #include "DrawingSelection.h" +#include "ShapeRecognition.h" #include #include #include diff --git a/cpp/DrawingSelection.h b/cpp/DrawingSelection.h index fa6c9cb..d70c1c5 100644 --- a/cpp/DrawingSelection.h +++ b/cpp/DrawingSelection.h @@ -4,7 +4,8 @@ #include #include #include -#include "SkiaDrawingEngine.h" +#include +#include "DrawingTypes.h" namespace nativedrawing { diff --git a/cpp/DrawingSerialization.cpp b/cpp/DrawingSerialization.cpp index 3e3ab6c..17ad081 100644 --- a/cpp/DrawingSerialization.cpp +++ b/cpp/DrawingSerialization.cpp @@ -1,4 +1,7 @@ #include "DrawingSerialization.h" +#include "ShapeRecognition.h" +#include +#include #include #include #include diff --git a/cpp/DrawingSerialization.h b/cpp/DrawingSerialization.h index 0f898cd..b12ef90 100644 --- a/cpp/DrawingSerialization.h +++ b/cpp/DrawingSerialization.h @@ -2,7 +2,7 @@ #include #include -#include "SkiaDrawingEngine.h" +#include "DrawingTypes.h" namespace nativedrawing { diff --git a/cpp/DrawingTypes.cpp b/cpp/DrawingTypes.cpp new file mode 100644 index 0000000..ace5920 --- /dev/null +++ b/cpp/DrawingTypes.cpp @@ -0,0 +1,84 @@ +#include "DrawingTypes.h" + +#include + +#include + +namespace nativedrawing { + +void Stroke::ensureEraserCacheValid() const { + if (erasedBy.size() == cachedEraserCount) { + return; + } + + // Always rebuild from scratch to match live eraser rendering exactly. + cachedEraserPath.reset(); + cachedEraserCount = 0; + + if (erasedBy.empty()) { + return; + } + + // Match EraserRenderer::drawEraserCirclesAsStrokes: group circles into + // strokes, create a path through centers, then stroke it with round caps. + constexpr float STROKE_BREAK_FACTOR = 2.0f; + size_t strokeStart = 0; + + for (size_t i = 0; i <= erasedBy.size(); ++i) { + bool isLast = (i == erasedBy.size()); + bool breakStroke = isLast; + + if (!isLast && i > strokeStart) { + float dx = erasedBy[i].x - erasedBy[i - 1].x; + float dy = erasedBy[i].y - erasedBy[i - 1].y; + float dist = std::sqrt(dx * dx + dy * dy); + float avgRadius = (erasedBy[i].radius + erasedBy[i - 1].radius) / 2.0f; + if (dist > avgRadius * STROKE_BREAK_FACTOR) { + breakStroke = true; + } + } + + if (breakStroke && i > strokeStart) { + size_t segmentLen = i - strokeStart; + + if (segmentLen == 1) { + cachedEraserPath.addCircle( + erasedBy[strokeStart].x, + erasedBy[strokeStart].y, + erasedBy[strokeStart].radius + ); + } else { + SkPath strokePath; + strokePath.moveTo(erasedBy[strokeStart].x, erasedBy[strokeStart].y); + for (size_t j = strokeStart + 1; j < i; ++j) { + strokePath.lineTo(erasedBy[j].x, erasedBy[j].y); + } + + SkPaint strokePaint; + strokePaint.setStyle(SkPaint::kStroke_Style); + strokePaint.setStrokeWidth(erasedBy[strokeStart].radius * 2.0f); + strokePaint.setStrokeCap(SkPaint::kRound_Cap); + strokePaint.setStrokeJoin(SkPaint::kRound_Join); + + SkPath filledPath; + if (skpathutils::FillPathWithPaint(strokePath, strokePaint, &filledPath)) { + cachedEraserPath.addPath(filledPath); + } else { + for (size_t j = strokeStart; j < i; ++j) { + cachedEraserPath.addCircle( + erasedBy[j].x, + erasedBy[j].y, + erasedBy[j].radius + ); + } + } + } + + strokeStart = i; + } + } + + cachedEraserCount = erasedBy.size(); +} + +} // namespace nativedrawing diff --git a/cpp/DrawingTypes.h b/cpp/DrawingTypes.h new file mode 100644 index 0000000..5b3639d --- /dev/null +++ b/cpp/DrawingTypes.h @@ -0,0 +1,127 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace nativedrawing { + +struct Point { + float x; + float y; + float pressure; + float azimuthAngle; // Pen tilt angle in radians + float altitude; // Pen altitude angle (perpendicular = pi/2) + float calculatedWidth; // Width calculated from pressure + altitude + long timestamp; +}; + +// Pixel eraser circle - stored per-stroke for rendering-time clipping +struct EraserCircle { + float x; + float y; + float radius; +}; + +struct Stroke { + std::vector points; + SkPaint paint; + SkPath path; + bool isEraser = false; // Mark eraser strokes to prevent selection + std::unordered_set affectedStrokeIndices; // For eraser strokes: which strokes they erase + float originalAlphaMod = 1.0f; // Preserve original opacity modifier for consistent appearance + std::string toolType = "pen"; // Tool type for specialized rendering (e.g., crayon needs texture) + float pathLength = 0.0f; // Cached total arc-length of the path + + // Per-stroke eraser data: circles that have erased portions of this stroke + // These move WITH the stroke when selected/moved, ensuring pixel-perfect appearance + // Empty = no erasure, rendering uses full stroke path + std::vector erasedBy; + + // OPTIMIZATION: Cached eraser path for O(1) rendering instead of O(n) path ops per frame + mutable SkPath cachedEraserPath; // Union of all erasedBy circles + mutable size_t cachedEraserCount = 0; // Track when to rebuild cache + + // OPTIMIZATION: Cached visibility for O(1) selection instead of O(m*k) per frame + mutable bool cachedHasVisiblePoints = true; // True if any point is outside all eraser circles + + // Ensure cachedEraserPath is up-to-date with erasedBy circles + // Builds stroked path matching EraserRenderer::drawEraserCirclesAsStrokes + void ensureEraserCacheValid() const; +}; + +// Delta-based history. Each entry describes ONE operation that was +// applied to strokes_, sized in proportion to the operation -- a single +// stroke add is ~1-3 KB, a pixel erase pass over K strokes is ~K * 12 +// bytes per circle, etc. This replaces the previous full-snapshot +// approach where each history entry contained the entire strokes_ +// vector deep-copy: with that model, total history memory grew linearly +// with stroke count even though the entry COUNT was capped, because +// each new snapshot was bigger than the last. On a long drawing +// session this was the dominant cause of the user-reported steady RAM +// climb past 2 GB (47 million live small allocations from duplicated +// stroke point/path vectors across history snapshots). +// +// Undo applies the inverse of the operation in-place; redo applies it +// forward. strokes_ is the canonical state between operations; the +// history is just a sequence of "what happened." +struct StrokeDelta { + enum class Kind : uint8_t { + AddStrokes, // appended at end of strokes_ (touchEnded normal stroke, paste) + RemoveStrokes, // removed at indices (object eraser, delete selection) + PixelErase, // erasedBy circles appended to one or more strokes (pixel eraser) + MoveStrokes, // strokes translated by (dx, dy) (selection finalize-move) + ReplaceStrokes, // selected strokes transformed in place (resize handles) + Clear, // all strokes wiped (clear button, full-screen erase) + }; + Kind kind; + + // For AddStrokes: the strokes that were appended (1 entry for normal + // pen stroke, N entries for paste). Undo = pop_back N, redo = push_back from delta. + std::vector addedStrokes; + + // For RemoveStrokes: pairs of (originalIndex, stroke) in ASCENDING + // index order. Undo re-inserts in ascending order (later inserts see + // valid indices because earlier ones already shifted things into + // place). Redo erases in DESCENDING order so each erase doesn't + // invalidate higher indices. + std::vector> removedStrokes; + + // For PixelErase: one entry per stroke that received eraser circles + // during this op. addedCircles holds the actual circle data so redo + // can re-append them; undo pops addedCircles.size() entries from the + // affected stroke's erasedBy. + struct PixelEraseEntry { + size_t strokeIndex; + std::vector addedCircles; + }; + std::vector pixelEraseEntries; + + // For MoveStrokes: indices of moved strokes + total translation. + // Undo = translate by (-dx, -dy); redo = (dx, dy). We re-run path + // smoothing after the translate either way, so the cached SkPath + // matches. + std::vector moveIndices; + float moveDx = 0.0f; + float moveDy = 0.0f; + + // For ReplaceStrokes: exact before/after snapshots for transformed + // selected strokes. Used by native selection handles where the edit is + // a scale/reshape, not a pure translation. + std::vector> beforeStrokes; + std::vector> afterStrokes; + + // For Clear: the strokes (and any global eraser circles) that + // existed before the clear, so undo can restore them. Bounded to + // exactly one snapshot per clear op, not per stroke. + std::vector clearedStrokes; + std::vector clearedEraserCircles; +}; + +} // namespace nativedrawing diff --git a/cpp/EraserRenderer.h b/cpp/EraserRenderer.h index 032a186..2b4b6c5 100644 --- a/cpp/EraserRenderer.h +++ b/cpp/EraserRenderer.h @@ -4,7 +4,7 @@ #include #include #include -#include "SkiaDrawingEngine.h" +#include "DrawingTypes.h" namespace nativedrawing { diff --git a/cpp/PathRenderer.cpp b/cpp/PathRenderer.cpp index ad5e48a..2cdcd62 100644 --- a/cpp/PathRenderer.cpp +++ b/cpp/PathRenderer.cpp @@ -1,8 +1,7 @@ #include "PathRenderer.h" + +#include #include -#include -#include -#include namespace nativedrawing { @@ -254,462 +253,6 @@ void PathRenderer::smoothPath(const std::vector& points, SkPath& path) { } } -sk_sp PathRenderer::createCrayonShader(SkColor baseColor, float pressure, float width, float strokeAngle) { - // ===== CHECK SHADER CACHE FIRST ===== - uint64_t cacheKey = getShaderCacheKey(baseColor, pressure, strokeAngle); - auto cacheIt = shaderCache_.find(cacheKey); - if (cacheIt != shaderCache_.end()) { - return cacheIt->second; // Return cached shader - HUGE performance win! - } - - // ===== PENCILKIT-STYLE CRAYON TEXTURE ===== - // Key characteristics: - // 1. Very visible directional streaks (lined texture) - // 2. DRAMATIC pressure variance (light = almost nothing, heavy = solid) - // 3. Sharp on/off threshold (no mushy in-between) - - // ===== LAYER 1: STRONG DIRECTIONAL STREAKS ===== - // Extreme anisotropy for visible "lined" texture like PencilKit - float freqAlong = 0.008f; // Very low = long continuous streaks - float freqAcross = 0.35f; // Very high = fine perpendicular detail (44:1 ratio) - - sk_sp grainNoise = SkShaders::MakeTurbulence( - freqAlong, freqAcross, - 4, // High octaves for detail - 0.0f, - nullptr - ); - - // Rotate grain to follow stroke direction - SkMatrix rotationMatrix; - float angleDegrees = strokeAngle * (180.0f / 3.14159265f); - rotationMatrix.setRotate(angleDegrees); - sk_sp rotatedGrain = grainNoise->makeWithLocalMatrix(rotationMatrix); - - // ===== LAYER 2: PAPER GRAIN (finer) ===== - // Simulates paper texture that wax deposits on - sk_sp paperNoise = SkShaders::MakeTurbulence( - 0.12f, 0.12f, // Finer grain for paper texture - 3, - 42.0f, - nullptr - ); - - // ===== COMBINE: Multiply creates realistic wax-on-paper ===== - sk_sp combinedNoise = SkShaders::Blend( - SkBlendMode::kMultiply, - rotatedGrain, - paperNoise - ); - - // ===== DRAMATIC PRESSURE-CONTROLLED THRESHOLD ===== - // PencilKit has HUGE variance: light = barely visible, heavy = nearly solid - // - // Scale: Higher = sharper threshold (less gray, more black/white) - // Offset: Controls coverage (negative = sparse, positive = dense) - // - // Key: Light pressure must be REALLY light, solid only at heavy pressure - // Light pressure (0.1): offset=-1.05 -> extremely sparse - // Medium pressure (0.5): offset=-0.45 -> moderate coverage - // Heavy pressure (1.0): offset=0.3 -> solid - float scale = 1.5f + pressure * 1.0f; // 1.5 to 2.5 - moderate sharpness - float offset = -1.2f + pressure * 1.5f; // -1.2 to 0.3 - very gradual ramp - - float alphaMatrix[20] = { - 0, 0, 0, 0, SkColorGetR(baseColor) / 255.0f, - 0, 0, 0, 0, SkColorGetG(baseColor) / 255.0f, - 0, 0, 0, 0, SkColorGetB(baseColor) / 255.0f, - scale, scale, scale, 0, offset - }; - - sk_sp thresholdFilter = SkColorFilters::Matrix(alphaMatrix); - - sk_sp resultShader = combinedNoise->makeWithColorFilter(thresholdFilter); - - // Cache the shader for future reuse - shaderCache_[cacheKey] = resultShader; - - return resultShader; -} - -void PathRenderer::drawCrayonPath( - SkCanvas* canvas, - const std::vector& points, - const SkPaint& basePaint, - bool applyPressureAlpha -) { - if (points.empty() || points.size() < 2 || !canvas) return; - - SkColor baseColor = basePaint.getColor(); - float strokeWidth = basePaint.getStrokeWidth(); - - // Generate smoothed points with pressure using helper - std::vector smoothedPoints; - interpolateSplinePoints(points, smoothedPoints, true); - - // Build edges using helper - std::vector edges; - if (!buildEdgePoints(smoothedPoints, edges)) return; - - // ===== BATCH RENDERING WITH PER-POINT PRESSURE ===== - // OPTIMIZED: Increased batch size for 50% fewer shader lookups - // Texture noise masks batch boundaries, so larger batches are visually acceptable - constexpr int BATCH_SIZE = 24; - - for (size_t batchStart = 0; batchStart < edges.size() - 1; batchStart += BATCH_SIZE) { - size_t batchEnd = std::min(batchStart + BATCH_SIZE, edges.size() - 1); - - // Calculate batch pressure and angle (smooth local average) - float batchPressure = 0.0f; - float batchDx = 0.0f, batchDy = 0.0f; - for (size_t i = batchStart; i <= batchEnd; i++) { - batchPressure += edges[i].pressure; - batchDx += std::cos(edges[i].angle); - batchDy += std::sin(edges[i].angle); - } - batchPressure /= (batchEnd - batchStart + 1); - batchPressure = std::max(0.1f, std::min(1.0f, batchPressure)); - float batchAngle = std::atan2(batchDy, batchDx); - - // Create shader with this batch's pressure - sk_sp batchShader = createCrayonShader(baseColor, batchPressure, strokeWidth, batchAngle); - - SkPaint batchPaint; - batchPaint.setShader(batchShader); - batchPaint.setStyle(SkPaint::kFill_Style); - batchPaint.setAntiAlias(false); // No AA - let texture create rough edges - batchPaint.setAlpha(255); - - // Build path for this micro-batch - SkPath batchPath; - batchPath.moveTo(edges[batchStart].left); - - for (size_t i = batchStart + 1; i <= batchEnd; i++) { - batchPath.lineTo(edges[i].left); - } - - for (int i = static_cast(batchEnd); i >= static_cast(batchStart); i--) { - batchPath.lineTo(edges[i].right); - } - - batchPath.close(); - canvas->drawPath(batchPath, batchPaint); - } - - // ===== TEXTURED END CAPS ===== - // Draw rounded caps at start and end with the crayon texture - // This makes the tip look curved and textured, not a straight line - - if (smoothedPoints.size() >= 2) { - // END CAP - semicircle at the last point - size_t lastIdx = smoothedPoints.size() - 1; - const Point& endPt = smoothedPoints[lastIdx]; - float endHalfWidth = endPt.calculatedWidth / 2.0f; - - // Direction at end (from previous to current) - float endDx = endPt.x - smoothedPoints[lastIdx - 1].x; - float endDy = endPt.y - smoothedPoints[lastIdx - 1].y; - float endLen = std::sqrt(endDx * endDx + endDy * endDy); - if (endLen > 0.001f) { endDx /= endLen; endDy /= endLen; } - - // Create shader for end cap - float endAngle = std::atan2(endDy, endDx); - sk_sp endCapShader = createCrayonShader(baseColor, endPt.pressure, strokeWidth, endAngle); - - SkPaint endCapPaint; - endCapPaint.setShader(endCapShader); - endCapPaint.setStyle(SkPaint::kFill_Style); - endCapPaint.setAntiAlias(false); - endCapPaint.setAlpha(255); - - // Build semicircle path for end cap - // Arc from left edge to right edge, curving outward in stroke direction - SkPath endCapPath; - float perpX = -endDy; - float perpY = endDx; - - // Start at left edge point - SkPoint endLeft = SkPoint::Make(endPt.x + perpX * endHalfWidth, endPt.y + perpY * endHalfWidth); - SkPoint endRight = SkPoint::Make(endPt.x - perpX * endHalfWidth, endPt.y - perpY * endHalfWidth); - - endCapPath.moveTo(endLeft); - - // Arc sweep angle direction (from left to right via the tip) - float endArcAngle = std::atan2(perpY, perpX) * 180.0f / 3.14159265f; - SkRect endCapRect = SkRect::MakeXYWH( - endPt.x - endHalfWidth, - endPt.y - endHalfWidth, - endHalfWidth * 2.0f, - endHalfWidth * 2.0f); - endCapPath.arcTo(endCapRect, endArcAngle, -180.0f, false); - endCapPath.close(); - - canvas->drawPath(endCapPath, endCapPaint); - - // START CAP - semicircle at the first point - const Point& startPt = smoothedPoints[0]; - float startHalfWidth = startPt.calculatedWidth / 2.0f; - - // Direction at start (from current to next) - float startDx = smoothedPoints[1].x - startPt.x; - float startDy = smoothedPoints[1].y - startPt.y; - float startLen = std::sqrt(startDx * startDx + startDy * startDy); - if (startLen > 0.001f) { startDx /= startLen; startDy /= startLen; } - - // Create shader for start cap - float startAngle = std::atan2(startDy, startDx); - sk_sp startCapShader = createCrayonShader(baseColor, startPt.pressure, strokeWidth, startAngle); - - SkPaint startCapPaint; - startCapPaint.setShader(startCapShader); - startCapPaint.setStyle(SkPaint::kFill_Style); - startCapPaint.setAntiAlias(false); - startCapPaint.setAlpha(255); - - // Build semicircle path for start cap (curves backward) - SkPath startCapPath; - float startPerpX = -startDy; - float startPerpY = startDx; - - SkPoint startRight = SkPoint::Make(startPt.x - startPerpX * startHalfWidth, startPt.y - startPerpY * startHalfWidth); - - startCapPath.moveTo(startRight); - - float startArcAngle = std::atan2(-startPerpY, -startPerpX) * 180.0f / 3.14159265f; - SkRect startCapRect = SkRect::MakeXYWH( - startPt.x - startHalfWidth, - startPt.y - startHalfWidth, - startHalfWidth * 2.0f, - startHalfWidth * 2.0f); - startCapPath.arcTo(startCapRect, startArcAngle, -180.0f, false); - startCapPath.close(); - - canvas->drawPath(startCapPath, startCapPaint); - } -} - -void PathRenderer::drawCrayonEndCaps( - SkCanvas* canvas, - const std::vector& points, - const SkPaint& basePaint -) { - if (points.empty() || points.size() < 2 || !canvas) return; - - SkColor baseColor = basePaint.getColor(); - float strokeWidth = basePaint.getStrokeWidth(); - - // Generate smoothed points - std::vector smoothedPoints; - interpolateSplinePoints(points, smoothedPoints, true); - - if (smoothedPoints.size() < 2) return; - - // ===== END CAP - semicircle at the last point ===== - size_t lastIdx = smoothedPoints.size() - 1; - const Point& endPt = smoothedPoints[lastIdx]; - float endHalfWidth = endPt.calculatedWidth / 2.0f; - - float endDx = endPt.x - smoothedPoints[lastIdx - 1].x; - float endDy = endPt.y - smoothedPoints[lastIdx - 1].y; - float endLen = std::sqrt(endDx * endDx + endDy * endDy); - if (endLen > 0.001f) { endDx /= endLen; endDy /= endLen; } - - float endAngle = std::atan2(endDy, endDx); - sk_sp endCapShader = createCrayonShader(baseColor, endPt.pressure, strokeWidth, endAngle); - - SkPaint endCapPaint; - endCapPaint.setShader(endCapShader); - endCapPaint.setStyle(SkPaint::kFill_Style); - endCapPaint.setAntiAlias(false); - endCapPaint.setAlpha(255); - - SkPath endCapPath; - float perpX = -endDy; - float perpY = endDx; - - SkPoint endLeft = SkPoint::Make(endPt.x + perpX * endHalfWidth, endPt.y + perpY * endHalfWidth); - endCapPath.moveTo(endLeft); - - float endArcAngle = std::atan2(perpY, perpX) * 180.0f / 3.14159265f; - SkRect endCapRect = SkRect::MakeXYWH( - endPt.x - endHalfWidth, - endPt.y - endHalfWidth, - endHalfWidth * 2.0f, - endHalfWidth * 2.0f); - endCapPath.arcTo(endCapRect, endArcAngle, -180.0f, false); - endCapPath.close(); - - canvas->drawPath(endCapPath, endCapPaint); - - // ===== START CAP - semicircle at the first point ===== - const Point& startPt = smoothedPoints[0]; - float startHalfWidth = startPt.calculatedWidth / 2.0f; - - float startDx = smoothedPoints[1].x - startPt.x; - float startDy = smoothedPoints[1].y - startPt.y; - float startLen = std::sqrt(startDx * startDx + startDy * startDy); - if (startLen > 0.001f) { startDx /= startLen; startDy /= startLen; } - - float startAngle = std::atan2(startDy, startDx); - sk_sp startCapShader = createCrayonShader(baseColor, startPt.pressure, strokeWidth, startAngle); - - SkPaint startCapPaint; - startCapPaint.setShader(startCapShader); - startCapPaint.setStyle(SkPaint::kFill_Style); - startCapPaint.setAntiAlias(false); - startCapPaint.setAlpha(255); - - SkPath startCapPath; - float startPerpX = -startDy; - float startPerpY = startDx; - - SkPoint startRight = SkPoint::Make(startPt.x - startPerpX * startHalfWidth, startPt.y - startPerpY * startHalfWidth); - startCapPath.moveTo(startRight); - - float startArcAngle = std::atan2(-startPerpY, -startPerpX) * 180.0f / 3.14159265f; - SkRect startCapRect = SkRect::MakeXYWH( - startPt.x - startHalfWidth, - startPt.y - startHalfWidth, - startHalfWidth * 2.0f, - startHalfWidth * 2.0f); - startCapPath.arcTo(startCapRect, startArcAngle, -180.0f, false); - startCapPath.close(); - - canvas->drawPath(startCapPath, startCapPaint); -} - -void PathRenderer::drawCrayonStartCap( - SkCanvas* canvas, - const std::vector& points, - const SkPaint& basePaint -) { - if (points.empty() || points.size() < 2 || !canvas) return; - - SkColor baseColor = basePaint.getColor(); - float strokeWidth = basePaint.getStrokeWidth(); - - // Generate smoothed points - std::vector smoothedPoints; - interpolateSplinePoints(points, smoothedPoints, true); - - if (smoothedPoints.size() < 2) return; - - // START CAP - semicircle at the first point - const Point& startPt = smoothedPoints[0]; - float startHalfWidth = startPt.calculatedWidth / 2.0f; - - float startDx = smoothedPoints[1].x - startPt.x; - float startDy = smoothedPoints[1].y - startPt.y; - float startLen = std::sqrt(startDx * startDx + startDy * startDy); - if (startLen > 0.001f) { startDx /= startLen; startDy /= startLen; } - - float startAngle = std::atan2(startDy, startDx); - sk_sp startCapShader = createCrayonShader(baseColor, startPt.pressure, strokeWidth, startAngle); - - SkPaint startCapPaint; - startCapPaint.setShader(startCapShader); - startCapPaint.setStyle(SkPaint::kFill_Style); - startCapPaint.setAntiAlias(false); - startCapPaint.setAlpha(255); - - SkPath startCapPath; - float startPerpX = -startDy; - float startPerpY = startDx; - - SkPoint startRight = SkPoint::Make(startPt.x - startPerpX * startHalfWidth, startPt.y - startPerpY * startHalfWidth); - startCapPath.moveTo(startRight); - - float startArcAngle = std::atan2(-startPerpY, -startPerpX) * 180.0f / 3.14159265f; - SkRect startCapRect = SkRect::MakeXYWH( - startPt.x - startHalfWidth, - startPt.y - startHalfWidth, - startHalfWidth * 2.0f, - startHalfWidth * 2.0f); - startCapPath.arcTo(startCapRect, startArcAngle, -180.0f, false); - startCapPath.close(); - - canvas->drawPath(startCapPath, startCapPaint); -} - -void PathRenderer::drawCrayonEndCap( - SkCanvas* canvas, - const std::vector& points, - const SkPaint& basePaint -) { - if (points.empty() || points.size() < 2 || !canvas) return; - - SkColor baseColor = basePaint.getColor(); - float strokeWidth = basePaint.getStrokeWidth(); - - // Generate smoothed points - std::vector smoothedPoints; - interpolateSplinePoints(points, smoothedPoints, true); - - if (smoothedPoints.size() < 2) return; - - // END CAP - semicircle at the last point - size_t lastIdx = smoothedPoints.size() - 1; - const Point& endPt = smoothedPoints[lastIdx]; - float endHalfWidth = endPt.calculatedWidth / 2.0f; - - float endDx = endPt.x - smoothedPoints[lastIdx - 1].x; - float endDy = endPt.y - smoothedPoints[lastIdx - 1].y; - float endLen = std::sqrt(endDx * endDx + endDy * endDy); - if (endLen > 0.001f) { endDx /= endLen; endDy /= endLen; } - - float endAngle = std::atan2(endDy, endDx); - sk_sp endCapShader = createCrayonShader(baseColor, endPt.pressure, strokeWidth, endAngle); - - SkPaint endCapPaint; - endCapPaint.setShader(endCapShader); - endCapPaint.setStyle(SkPaint::kFill_Style); - endCapPaint.setAntiAlias(false); - endCapPaint.setAlpha(255); - - SkPath endCapPath; - float perpX = -endDy; - float perpY = endDx; - - SkPoint endLeft = SkPoint::Make(endPt.x + perpX * endHalfWidth, endPt.y + perpY * endHalfWidth); - endCapPath.moveTo(endLeft); - - float endArcAngle = std::atan2(perpY, perpX) * 180.0f / 3.14159265f; - SkRect endCapRect = SkRect::MakeXYWH( - endPt.x - endHalfWidth, - endPt.y - endHalfWidth, - endHalfWidth * 2.0f, - endHalfWidth * 2.0f); - endCapPath.arcTo(endCapRect, endArcAngle, -180.0f, false); - endCapPath.close(); - - canvas->drawPath(endCapPath, endCapPaint); -} - -// ===== SHADER CACHING IMPLEMENTATION ===== -// OPTIMIZED: Reduced quantization levels for 5x higher cache hit rate -// 8 pressure x 8 angle = 64 slots vs 320 before (perceptually equivalent for noise textures) - -uint64_t PathRenderer::getShaderCacheKey(SkColor color, float pressure, float angle) { - // Quantize pressure to 8 levels (3 bits) - sufficient for noise texture variance - int pressureLevel = static_cast(std::max(0.0f, std::min(1.0f, pressure)) * 7.99f); - - // Quantize angle to 8 buckets (3 bits) - 45 degrees each - // Normalize angle to 0-2PI range first - float normalizedAngle = angle; - while (normalizedAngle < 0) normalizedAngle += 2.0f * 3.14159265f; - while (normalizedAngle >= 2.0f * 3.14159265f) normalizedAngle -= 2.0f * 3.14159265f; - int angleBucket = static_cast(normalizedAngle / (2.0f * 3.14159265f) * 8) % 8; - - // Combine: color (32 bits) | pressure (3 bits) | angle (3 bits) - return (static_cast(color) << 6) | - (static_cast(pressureLevel) << 3) | - static_cast(angleBucket); -} - -void PathRenderer::clearShaderCache() { - shaderCache_.clear(); -} - // ===== SPLINE INTERPOLATION HELPERS ===== void PathRenderer::interpolateSplinePoints( @@ -807,149 +350,6 @@ bool PathRenderer::buildEdgePoints( // ===== INCREMENTAL RENDERING METHODS ===== -IncrementalResult PathRenderer::drawCrayonPathIncremental( - SkCanvas* canvas, - const std::vector& points, - const SkPaint& basePaint, - const SkPoint& prevLeftEdge, - const SkPoint& prevRightEdge, - bool isFirstSegment -) { - IncrementalResult result = {}; - if (points.empty() || points.size() < 2 || !canvas) return result; - - SkColor baseColor = basePaint.getColor(); - float strokeWidth = basePaint.getStrokeWidth(); - - // Generate smoothed points using helper - std::vector smoothedPoints; - interpolateSplinePoints( - points, - smoothedPoints, - true, - LIVE_SEGMENTS_PER_SPAN, - LIVE_SPLINE_TENSION - ); - - // Build edges using helper - std::vector edges; - if (!buildEdgePoints(smoothedPoints, edges)) return result; - - // Connect to previous segment (replace first edge with previous edge) - if (!isFirstSegment) { - edges[0].left = prevLeftEdge; - edges[0].right = prevRightEdge; - } - - // Batch rendering (same as drawCrayonPath) - constexpr int BATCH_SIZE = 24; - for (size_t batchStart = 0; batchStart < edges.size() - 1; batchStart += BATCH_SIZE) { - size_t batchEnd = std::min(batchStart + BATCH_SIZE, edges.size() - 1); - - float batchPressure = 0.0f; - float batchDx = 0.0f, batchDy = 0.0f; - for (size_t i = batchStart; i <= batchEnd; i++) { - batchPressure += edges[i].pressure; - batchDx += std::cos(edges[i].angle); - batchDy += std::sin(edges[i].angle); - } - batchPressure = std::max(0.1f, std::min(1.0f, batchPressure / (batchEnd - batchStart + 1))); - float batchAngle = std::atan2(batchDy, batchDx); - - sk_sp batchShader = createCrayonShader(baseColor, batchPressure, strokeWidth, batchAngle); - - SkPaint batchPaint; - batchPaint.setShader(batchShader); - batchPaint.setStyle(SkPaint::kFill_Style); - batchPaint.setAntiAlias(false); - batchPaint.setAlpha(255); - - SkPath batchPath; - batchPath.moveTo(edges[batchStart].left); - for (size_t i = batchStart + 1; i <= batchEnd; i++) { - batchPath.lineTo(edges[i].left); - } - for (int i = static_cast(batchEnd); i >= static_cast(batchStart); i--) { - batchPath.lineTo(edges[i].right); - } - batchPath.close(); - canvas->drawPath(batchPath, batchPaint); - } - - // Return last edge for next segment connection - result.lastLeftEdge = edges.back().left; - result.lastRightEdge = edges.back().right; - result.lastPressure = edges.back().pressure; - result.lastAngle = edges.back().angle; - result.smoothedPointsRendered = smoothedPoints.size(); - - return result; -} - -void PathRenderer::drawCrayonPathTail( - SkCanvas* canvas, - const std::vector& points, - const SkPaint& basePaint, - const SkPoint& prevLeftEdge, - const SkPoint& prevRightEdge, - bool hasPreviousEdge -) { - // Tail is the recent points showing the current pen position - // We render it with an end cap so the tip looks rounded while drawing - if (points.size() < 2) return; - - // First, draw the body using incremental rendering - IncrementalResult result = drawCrayonPathIncremental( - canvas, points, basePaint, prevLeftEdge, prevRightEdge, !hasPreviousEdge); - - // Add end cap to the tail tip (so user sees rounded tip while drawing) - SkColor baseColor = basePaint.getColor(); - float strokeWidth = basePaint.getStrokeWidth(); - - // Generate smoothed points to get the last point position - std::vector smoothedPoints; - interpolateSplinePoints(points, smoothedPoints, true); - - if (smoothedPoints.size() >= 2) { - // Draw textured end cap at the tip - size_t lastIdx = smoothedPoints.size() - 1; - const Point& endPt = smoothedPoints[lastIdx]; - float endHalfWidth = endPt.calculatedWidth / 2.0f; - - float endDx = endPt.x - smoothedPoints[lastIdx - 1].x; - float endDy = endPt.y - smoothedPoints[lastIdx - 1].y; - float endLen = std::sqrt(endDx * endDx + endDy * endDy); - if (endLen > 0.001f) { endDx /= endLen; endDy /= endLen; } - - float endAngle = std::atan2(endDy, endDx); - sk_sp endCapShader = createCrayonShader(baseColor, endPt.pressure, strokeWidth, endAngle); - - SkPaint endCapPaint; - endCapPaint.setShader(endCapShader); - endCapPaint.setStyle(SkPaint::kFill_Style); - endCapPaint.setAntiAlias(false); - endCapPaint.setAlpha(255); - - SkPath endCapPath; - float perpX = -endDy; - float perpY = endDx; - - SkPoint endLeft = SkPoint::Make(endPt.x + perpX * endHalfWidth, endPt.y + perpY * endHalfWidth); - endCapPath.moveTo(endLeft); - - float endArcAngle = std::atan2(perpY, perpX) * 180.0f / 3.14159265f; - SkRect endCapRect = SkRect::MakeXYWH( - endPt.x - endHalfWidth, - endPt.y - endHalfWidth, - endHalfWidth * 2.0f, - endHalfWidth * 2.0f); - endCapPath.arcTo(endCapRect, endArcAngle, -180.0f, false); - endCapPath.close(); - - canvas->drawPath(endCapPath, endCapPaint); - } -} - IncrementalResult PathRenderer::drawVariableWidthPathIncremental( SkCanvas* canvas, const std::vector& points, @@ -1194,360 +594,4 @@ void PathRenderer::drawVariableWidthEndCap( canvas->drawPath(endCapPath, fillPaint); } -// ===== CALLIGRAPHY BRUSH IMPLEMENTATION ===== -// Pointed/flex nib style: thin upstrokes, thick downstrokes - -float PathRenderer::calculateVelocity(const Point& current, const Point& previous) { - float dx = current.x - previous.x; - float dy = current.y - previous.y; - float distance = std::sqrt(dx * dx + dy * dy); - - // Timestamp difference in milliseconds - long timeDelta = current.timestamp - previous.timestamp; - if (timeDelta <= 0) { - // Fallback: estimate based on typical touch sampling rate (120Hz = 8.3ms) - timeDelta = 8; - } - - // Velocity in pixels per second - return (distance / static_cast(timeDelta)) * 1000.0f; -} - -float PathRenderer::calculateVerticalDirection(const Point& current, const Point& previous) { - float dx = current.x - previous.x; - float dy = current.y - previous.y; - float distance = std::sqrt(dx * dx + dy * dy); - - if (distance < 0.001f) return 0.0f; // No movement - - // Normalize dy to get vertical component of direction - // Positive = downstroke (moving down), negative = upstroke (moving up) - return dy / distance; -} - -float PathRenderer::calculateCalligraphyWidth( - const Point& current, - const Point& previous, - float baseWidth, - float velocity, - float verticalDirection -) { - // === DIRECTION FACTOR === - // Downstrokes (positive Y direction) are thick, upstrokes are thin - // verticalDirection: -1.0 (pure upstroke) to +1.0 (pure downstroke) - // Map to width multiplier: 0.3x (thin upstroke) to 1.5x (thick downstroke) - float directionFactor = 0.9f + (verticalDirection * 0.6f); // 0.3 to 1.5 - - // === VELOCITY FACTOR === - // Fast strokes = thinner, slow strokes = thicker - // Velocity typically ranges from 0 (stationary) to 3000+ px/sec - // Normalize to 0-1 range where 0 = fast (>2000px/s), 1 = slow (<200px/s) - float velocityNormalized = 1.0f - std::min(1.0f, velocity / 2000.0f); - float velocityFactor = 0.6f + (velocityNormalized * 0.4f); // 0.6x to 1.0x - - // === PRESSURE FACTOR === - // Pressure amplifies the direction effect - // Light pressure = less width variation, heavy pressure = more dramatic - float pressure = current.pressure; - float pressureFactor = 0.5f + (pressure * 0.5f); // 0.5x to 1.0x base - - // === COMBINE FACTORS === - // Direction is primary, velocity and pressure modulate - float combinedFactor = directionFactor * velocityFactor * pressureFactor; - - // Clamp to reasonable range (0.15x to 2.0x of base width) - combinedFactor = std::max(0.15f, std::min(2.0f, combinedFactor)); - - return baseWidth * combinedFactor; -} - -bool PathRenderer::buildCalligraphyEdgePoints( - const std::vector& smoothedPoints, - std::vector& edges, - float baseWidth, - float initialHalfWidth, - float* outFinalHalfWidth -) { - if (smoothedPoints.size() < 2) return false; - edges.reserve(smoothedPoints.size()); - - float prevVelocity = 0.0f; - float prevDirection = 0.0f; - float prevHalfWidth = (initialHalfWidth > 0) ? initialHalfWidth : (baseWidth / 2.0f); - - for (size_t i = 0; i < smoothedPoints.size(); i++) { - const Point& p = smoothedPoints[i]; - - // Calculate velocity and direction from previous point - float velocity = 0.0f; - float verticalDirection = 0.0f; - - if (i > 0) { - velocity = calculateVelocity(p, smoothedPoints[i - 1]); - verticalDirection = calculateVerticalDirection(p, smoothedPoints[i - 1]); - - // Smooth velocity and direction to avoid jitter - velocity = 0.7f * prevVelocity + 0.3f * velocity; - verticalDirection = 0.8f * prevDirection + 0.2f * verticalDirection; - } - - prevVelocity = velocity; - prevDirection = verticalDirection; - - // Calculate calligraphy width - Point prevPoint = (i > 0) ? smoothedPoints[i - 1] : p; - float targetHalfWidth = calculateCalligraphyWidth(p, prevPoint, baseWidth, velocity, verticalDirection) / 2.0f; - - // CRITICAL: Limit rate of width change to prevent edge crossing - // Max change is 20% of previous width per point - float maxChange = prevHalfWidth * 0.2f; - float halfWidth; - if (targetHalfWidth > prevHalfWidth + maxChange) { - halfWidth = prevHalfWidth + maxChange; - } else if (targetHalfWidth < prevHalfWidth - maxChange) { - halfWidth = prevHalfWidth - maxChange; - } else { - halfWidth = targetHalfWidth; - } - prevHalfWidth = halfWidth; - - // Calculate perpendicular direction - float dx = 0.0f, dy = 0.0f; - if (i < smoothedPoints.size() - 1) { - dx = smoothedPoints[i + 1].x - p.x; - dy = smoothedPoints[i + 1].y - p.y; - } else if (i > 0) { - dx = p.x - smoothedPoints[i - 1].x; - dy = p.y - smoothedPoints[i - 1].y; - } - - float len = std::sqrt(dx * dx + dy * dy); - if (len > 0.001f) { - dx /= len; - dy /= len; - } - - float perpX = -dy; - float perpY = dx; - - EdgePoint ep; - ep.left = SkPoint::Make(p.x + perpX * halfWidth, p.y + perpY * halfWidth); - ep.right = SkPoint::Make(p.x - perpX * halfWidth, p.y - perpY * halfWidth); - ep.pressure = p.pressure; - ep.angle = std::atan2(dy, dx); - edges.push_back(ep); - } - - // Output final half width for incremental continuity - if (outFinalHalfWidth) { - *outFinalHalfWidth = prevHalfWidth; - } - - return true; -} - -void PathRenderer::drawCalligraphyPath( - SkCanvas* canvas, - const std::vector& points, - const SkPaint& basePaint, - bool applyPressureAlpha -) { - if (points.empty() || points.size() < 2 || !canvas) return; - - float baseWidth = basePaint.getStrokeWidth(); - - // Generate smoothed points with timestamp interpolation - std::vector smoothedPoints; - interpolateSplinePoints(points, smoothedPoints, true); - - // Build edges with calligraphy-specific width calculation - std::vector edges; - if (!buildCalligraphyEdgePoints(smoothedPoints, edges, baseWidth)) return; - - // Create path from edges (same as variable-width path) - SkPath strokePath; - strokePath.moveTo(edges[0].left); - - // Left edge forward - for (size_t i = 1; i < edges.size(); i++) { - strokePath.lineTo(edges[i].left); - } - - // End cap - smooth semicircle - if (edges.size() >= 2) { - const EdgePoint& lastEdge = edges.back(); - float perpLen = std::sqrt( - (lastEdge.left.x() - lastEdge.right.x()) * (lastEdge.left.x() - lastEdge.right.x()) + - (lastEdge.left.y() - lastEdge.right.y()) * (lastEdge.left.y() - lastEdge.right.y()) - ) / 2.0f; - - SkPoint center = SkPoint::Make( - (lastEdge.left.x() + lastEdge.right.x()) / 2.0f, - (lastEdge.left.y() + lastEdge.right.y()) / 2.0f - ); - - // Tapered end cap using quadratic bezier - float dx = smoothedPoints.back().x - smoothedPoints[smoothedPoints.size() - 2].x; - float dy = smoothedPoints.back().y - smoothedPoints[smoothedPoints.size() - 2].y; - float len = std::sqrt(dx * dx + dy * dy); - if (len > 0.001f) { dx /= len; dy /= len; } - - SkPoint tipPoint = SkPoint::Make(center.x() + dx * perpLen * 0.8f, center.y() + dy * perpLen * 0.8f); - strokePath.quadTo(tipPoint, lastEdge.right); - } - - // Right edge backward - for (int i = static_cast(edges.size()) - 2; i >= 0; i--) { - strokePath.lineTo(edges[i].right); - } - - // Start cap - tapered - if (edges.size() >= 2) { - const EdgePoint& firstEdge = edges[0]; - SkPoint center = SkPoint::Make( - (firstEdge.left.x() + firstEdge.right.x()) / 2.0f, - (firstEdge.left.y() + firstEdge.right.y()) / 2.0f - ); - - float perpLen = std::sqrt( - (firstEdge.left.x() - firstEdge.right.x()) * (firstEdge.left.x() - firstEdge.right.x()) + - (firstEdge.left.y() - firstEdge.right.y()) * (firstEdge.left.y() - firstEdge.right.y()) - ) / 2.0f; - - float dx = smoothedPoints[1].x - smoothedPoints[0].x; - float dy = smoothedPoints[1].y - smoothedPoints[0].y; - float len = std::sqrt(dx * dx + dy * dy); - if (len > 0.001f) { dx /= len; dy /= len; } - - SkPoint tipPoint = SkPoint::Make(center.x() - dx * perpLen * 0.8f, center.y() - dy * perpLen * 0.8f); - strokePath.quadTo(tipPoint, firstEdge.left); - } - - strokePath.close(); - - // Create paint for filled stroke - SkPaint fillPaint = basePaint; - fillPaint.setStyle(SkPaint::kFill_Style); - fillPaint.setAntiAlias(true); - - canvas->drawPath(strokePath, fillPaint); -} - -IncrementalResult PathRenderer::drawCalligraphyPathIncremental( - SkCanvas* canvas, - const std::vector& points, - const SkPaint& basePaint, - const SkPoint& prevLeftEdge, - const SkPoint& prevRightEdge, - bool isFirstSegment, - float initialHalfWidth -) { - IncrementalResult result = {}; - if (points.empty() || points.size() < 2 || !canvas) return result; - - float baseWidth = basePaint.getStrokeWidth(); - - // Generate smoothed points - std::vector smoothedPoints; - interpolateSplinePoints(points, smoothedPoints, true); - - // Build edges with calligraphy width, passing initial width for continuity - std::vector edges; - float finalHalfWidth = 0.0f; - if (!buildCalligraphyEdgePoints(smoothedPoints, edges, baseWidth, initialHalfWidth, &finalHalfWidth)) return result; - - // Connect to previous segment - if (!isFirstSegment) { - edges[0].left = prevLeftEdge; - edges[0].right = prevRightEdge; - } - - // Build path using simple lines - SkPath path; - path.moveTo(edges[0].left); - - // Left edge forward - for (size_t i = 1; i < edges.size(); i++) { - path.lineTo(edges[i].left); - } - - // Right edge backward - for (int i = static_cast(edges.size()) - 1; i >= 0; i--) { - path.lineTo(edges[i].right); - } - - path.close(); - - SkPaint fillPaint = basePaint; - fillPaint.setStyle(SkPaint::kFill_Style); - fillPaint.setAntiAlias(true); - - canvas->drawPath(path, fillPaint); - - // Return edge data for next segment - result.lastLeftEdge = edges.back().left; - result.lastRightEdge = edges.back().right; - result.lastPressure = edges.back().pressure; - result.lastAngle = edges.back().angle; - result.lastHalfWidth = finalHalfWidth; - result.smoothedPointsRendered = smoothedPoints.size(); - - return result; -} - -void PathRenderer::drawCalligraphyPathTail( - SkCanvas* canvas, - const std::vector& points, - const SkPaint& basePaint, - const SkPoint& prevLeftEdge, - const SkPoint& prevRightEdge, - bool hasPreviousEdge, - float initialHalfWidth -) { - if (points.size() < 2) return; - - // Use incremental rendering for tail, passing width for continuity - IncrementalResult result = drawCalligraphyPathIncremental( - canvas, points, basePaint, prevLeftEdge, prevRightEdge, !hasPreviousEdge, initialHalfWidth); - - // Add end cap for visual feedback while drawing - // Use the rate-limited width from incremental result for consistency - if (points.size() >= 2 && result.lastHalfWidth > 0) { - std::vector smoothedPoints; - interpolateSplinePoints(points, smoothedPoints, true); - - if (smoothedPoints.size() >= 2) { - const Point& endPt = smoothedPoints.back(); - const Point& prevPt = smoothedPoints[smoothedPoints.size() - 2]; - - // Use rate-limited width from incremental render (not recalculated) - // This ensures the cap matches the stroke body exactly - float endHalfWidth = result.lastHalfWidth; - - float dx = endPt.x - prevPt.x; - float dy = endPt.y - prevPt.y; - float len = std::sqrt(dx * dx + dy * dy); - if (len > 0.001f) { dx /= len; dy /= len; } - - float perpX = -dy; - float perpY = dx; - - SkPoint endLeft = SkPoint::Make(endPt.x + perpX * endHalfWidth, endPt.y + perpY * endHalfWidth); - SkPoint endRight = SkPoint::Make(endPt.x - perpX * endHalfWidth, endPt.y - perpY * endHalfWidth); - SkPoint tipPoint = SkPoint::Make(endPt.x + dx * endHalfWidth * 0.8f, endPt.y + dy * endHalfWidth * 0.8f); - - SkPath endCapPath; - endCapPath.moveTo(endLeft); - endCapPath.quadTo(tipPoint, endRight); - endCapPath.close(); - - SkPaint fillPaint = basePaint; - fillPaint.setStyle(SkPaint::kFill_Style); - fillPaint.setAntiAlias(true); - - canvas->drawPath(endCapPath, fillPaint); - } - } -} - } // namespace nativedrawing diff --git a/cpp/PathRenderer.h b/cpp/PathRenderer.h index eeaa0c8..399c705 100644 --- a/cpp/PathRenderer.h +++ b/cpp/PathRenderer.h @@ -6,7 +6,7 @@ #include #include #include -#include "SkiaDrawingEngine.h" +#include "DrawingTypes.h" namespace nativedrawing { diff --git a/cpp/PathRendererCalligraphy.cpp b/cpp/PathRendererCalligraphy.cpp new file mode 100644 index 0000000..b02c0d4 --- /dev/null +++ b/cpp/PathRendererCalligraphy.cpp @@ -0,0 +1,364 @@ +#include "PathRenderer.h" + +#include +#include + +namespace nativedrawing { + +// ===== CALLIGRAPHY BRUSH IMPLEMENTATION ===== +// Pointed/flex nib style: thin upstrokes, thick downstrokes + +float PathRenderer::calculateVelocity(const Point& current, const Point& previous) { + float dx = current.x - previous.x; + float dy = current.y - previous.y; + float distance = std::sqrt(dx * dx + dy * dy); + + // Timestamp difference in milliseconds + long timeDelta = current.timestamp - previous.timestamp; + if (timeDelta <= 0) { + // Fallback: estimate based on typical touch sampling rate (120Hz = 8.3ms) + timeDelta = 8; + } + + // Velocity in pixels per second + return (distance / static_cast(timeDelta)) * 1000.0f; +} + +float PathRenderer::calculateVerticalDirection(const Point& current, const Point& previous) { + float dx = current.x - previous.x; + float dy = current.y - previous.y; + float distance = std::sqrt(dx * dx + dy * dy); + + if (distance < 0.001f) return 0.0f; // No movement + + // Normalize dy to get vertical component of direction + // Positive = downstroke (moving down), negative = upstroke (moving up) + return dy / distance; +} + +float PathRenderer::calculateCalligraphyWidth( + const Point& current, + const Point& previous, + float baseWidth, + float velocity, + float verticalDirection +) { + // === DIRECTION FACTOR === + // Downstrokes (positive Y direction) are thick, upstrokes are thin + // verticalDirection: -1.0 (pure upstroke) to +1.0 (pure downstroke) + // Map to width multiplier: 0.3x (thin upstroke) to 1.5x (thick downstroke) + float directionFactor = 0.9f + (verticalDirection * 0.6f); // 0.3 to 1.5 + + // === VELOCITY FACTOR === + // Fast strokes = thinner, slow strokes = thicker + // Velocity typically ranges from 0 (stationary) to 3000+ px/sec + // Normalize to 0-1 range where 0 = fast (>2000px/s), 1 = slow (<200px/s) + float velocityNormalized = 1.0f - std::min(1.0f, velocity / 2000.0f); + float velocityFactor = 0.6f + (velocityNormalized * 0.4f); // 0.6x to 1.0x + + // === PRESSURE FACTOR === + // Pressure amplifies the direction effect + // Light pressure = less width variation, heavy pressure = more dramatic + float pressure = current.pressure; + float pressureFactor = 0.5f + (pressure * 0.5f); // 0.5x to 1.0x base + + // === COMBINE FACTORS === + // Direction is primary, velocity and pressure modulate + float combinedFactor = directionFactor * velocityFactor * pressureFactor; + + // Clamp to reasonable range (0.15x to 2.0x of base width) + combinedFactor = std::max(0.15f, std::min(2.0f, combinedFactor)); + + return baseWidth * combinedFactor; +} + +bool PathRenderer::buildCalligraphyEdgePoints( + const std::vector& smoothedPoints, + std::vector& edges, + float baseWidth, + float initialHalfWidth, + float* outFinalHalfWidth +) { + if (smoothedPoints.size() < 2) return false; + edges.reserve(smoothedPoints.size()); + + float prevVelocity = 0.0f; + float prevDirection = 0.0f; + float prevHalfWidth = (initialHalfWidth > 0) ? initialHalfWidth : (baseWidth / 2.0f); + + for (size_t i = 0; i < smoothedPoints.size(); i++) { + const Point& p = smoothedPoints[i]; + + // Calculate velocity and direction from previous point + float velocity = 0.0f; + float verticalDirection = 0.0f; + + if (i > 0) { + velocity = calculateVelocity(p, smoothedPoints[i - 1]); + verticalDirection = calculateVerticalDirection(p, smoothedPoints[i - 1]); + + // Smooth velocity and direction to avoid jitter + velocity = 0.7f * prevVelocity + 0.3f * velocity; + verticalDirection = 0.8f * prevDirection + 0.2f * verticalDirection; + } + + prevVelocity = velocity; + prevDirection = verticalDirection; + + // Calculate calligraphy width + Point prevPoint = (i > 0) ? smoothedPoints[i - 1] : p; + float targetHalfWidth = calculateCalligraphyWidth(p, prevPoint, baseWidth, velocity, verticalDirection) / 2.0f; + + // CRITICAL: Limit rate of width change to prevent edge crossing + // Max change is 20% of previous width per point + float maxChange = prevHalfWidth * 0.2f; + float halfWidth; + if (targetHalfWidth > prevHalfWidth + maxChange) { + halfWidth = prevHalfWidth + maxChange; + } else if (targetHalfWidth < prevHalfWidth - maxChange) { + halfWidth = prevHalfWidth - maxChange; + } else { + halfWidth = targetHalfWidth; + } + prevHalfWidth = halfWidth; + + // Calculate perpendicular direction + float dx = 0.0f, dy = 0.0f; + if (i < smoothedPoints.size() - 1) { + dx = smoothedPoints[i + 1].x - p.x; + dy = smoothedPoints[i + 1].y - p.y; + } else if (i > 0) { + dx = p.x - smoothedPoints[i - 1].x; + dy = p.y - smoothedPoints[i - 1].y; + } + + float len = std::sqrt(dx * dx + dy * dy); + if (len > 0.001f) { + dx /= len; + dy /= len; + } + + float perpX = -dy; + float perpY = dx; + + EdgePoint ep; + ep.left = SkPoint::Make(p.x + perpX * halfWidth, p.y + perpY * halfWidth); + ep.right = SkPoint::Make(p.x - perpX * halfWidth, p.y - perpY * halfWidth); + ep.pressure = p.pressure; + ep.angle = std::atan2(dy, dx); + edges.push_back(ep); + } + + // Output final half width for incremental continuity + if (outFinalHalfWidth) { + *outFinalHalfWidth = prevHalfWidth; + } + + return true; +} + +void PathRenderer::drawCalligraphyPath( + SkCanvas* canvas, + const std::vector& points, + const SkPaint& basePaint, + bool applyPressureAlpha +) { + if (points.empty() || points.size() < 2 || !canvas) return; + + float baseWidth = basePaint.getStrokeWidth(); + + // Generate smoothed points with timestamp interpolation + std::vector smoothedPoints; + interpolateSplinePoints(points, smoothedPoints, true); + + // Build edges with calligraphy-specific width calculation + std::vector edges; + if (!buildCalligraphyEdgePoints(smoothedPoints, edges, baseWidth)) return; + + // Create path from edges (same as variable-width path) + SkPath strokePath; + strokePath.moveTo(edges[0].left); + + // Left edge forward + for (size_t i = 1; i < edges.size(); i++) { + strokePath.lineTo(edges[i].left); + } + + // End cap - smooth semicircle + if (edges.size() >= 2) { + const EdgePoint& lastEdge = edges.back(); + float perpLen = std::sqrt( + (lastEdge.left.x() - lastEdge.right.x()) * (lastEdge.left.x() - lastEdge.right.x()) + + (lastEdge.left.y() - lastEdge.right.y()) * (lastEdge.left.y() - lastEdge.right.y()) + ) / 2.0f; + + SkPoint center = SkPoint::Make( + (lastEdge.left.x() + lastEdge.right.x()) / 2.0f, + (lastEdge.left.y() + lastEdge.right.y()) / 2.0f + ); + + // Tapered end cap using quadratic bezier + float dx = smoothedPoints.back().x - smoothedPoints[smoothedPoints.size() - 2].x; + float dy = smoothedPoints.back().y - smoothedPoints[smoothedPoints.size() - 2].y; + float len = std::sqrt(dx * dx + dy * dy); + if (len > 0.001f) { dx /= len; dy /= len; } + + SkPoint tipPoint = SkPoint::Make(center.x() + dx * perpLen * 0.8f, center.y() + dy * perpLen * 0.8f); + strokePath.quadTo(tipPoint, lastEdge.right); + } + + // Right edge backward + for (int i = static_cast(edges.size()) - 2; i >= 0; i--) { + strokePath.lineTo(edges[i].right); + } + + // Start cap - tapered + if (edges.size() >= 2) { + const EdgePoint& firstEdge = edges[0]; + SkPoint center = SkPoint::Make( + (firstEdge.left.x() + firstEdge.right.x()) / 2.0f, + (firstEdge.left.y() + firstEdge.right.y()) / 2.0f + ); + + float perpLen = std::sqrt( + (firstEdge.left.x() - firstEdge.right.x()) * (firstEdge.left.x() - firstEdge.right.x()) + + (firstEdge.left.y() - firstEdge.right.y()) * (firstEdge.left.y() - firstEdge.right.y()) + ) / 2.0f; + + float dx = smoothedPoints[1].x - smoothedPoints[0].x; + float dy = smoothedPoints[1].y - smoothedPoints[0].y; + float len = std::sqrt(dx * dx + dy * dy); + if (len > 0.001f) { dx /= len; dy /= len; } + + SkPoint tipPoint = SkPoint::Make(center.x() - dx * perpLen * 0.8f, center.y() - dy * perpLen * 0.8f); + strokePath.quadTo(tipPoint, firstEdge.left); + } + + strokePath.close(); + + // Create paint for filled stroke + SkPaint fillPaint = basePaint; + fillPaint.setStyle(SkPaint::kFill_Style); + fillPaint.setAntiAlias(true); + + canvas->drawPath(strokePath, fillPaint); +} + +IncrementalResult PathRenderer::drawCalligraphyPathIncremental( + SkCanvas* canvas, + const std::vector& points, + const SkPaint& basePaint, + const SkPoint& prevLeftEdge, + const SkPoint& prevRightEdge, + bool isFirstSegment, + float initialHalfWidth +) { + IncrementalResult result = {}; + if (points.empty() || points.size() < 2 || !canvas) return result; + + float baseWidth = basePaint.getStrokeWidth(); + + // Generate smoothed points + std::vector smoothedPoints; + interpolateSplinePoints(points, smoothedPoints, true); + + // Build edges with calligraphy width, passing initial width for continuity + std::vector edges; + float finalHalfWidth = 0.0f; + if (!buildCalligraphyEdgePoints(smoothedPoints, edges, baseWidth, initialHalfWidth, &finalHalfWidth)) return result; + + // Connect to previous segment + if (!isFirstSegment) { + edges[0].left = prevLeftEdge; + edges[0].right = prevRightEdge; + } + + // Build path using simple lines + SkPath path; + path.moveTo(edges[0].left); + + // Left edge forward + for (size_t i = 1; i < edges.size(); i++) { + path.lineTo(edges[i].left); + } + + // Right edge backward + for (int i = static_cast(edges.size()) - 1; i >= 0; i--) { + path.lineTo(edges[i].right); + } + + path.close(); + + SkPaint fillPaint = basePaint; + fillPaint.setStyle(SkPaint::kFill_Style); + fillPaint.setAntiAlias(true); + + canvas->drawPath(path, fillPaint); + + // Return edge data for next segment + result.lastLeftEdge = edges.back().left; + result.lastRightEdge = edges.back().right; + result.lastPressure = edges.back().pressure; + result.lastAngle = edges.back().angle; + result.lastHalfWidth = finalHalfWidth; + result.smoothedPointsRendered = smoothedPoints.size(); + + return result; +} + +void PathRenderer::drawCalligraphyPathTail( + SkCanvas* canvas, + const std::vector& points, + const SkPaint& basePaint, + const SkPoint& prevLeftEdge, + const SkPoint& prevRightEdge, + bool hasPreviousEdge, + float initialHalfWidth +) { + if (points.size() < 2) return; + + // Use incremental rendering for tail, passing width for continuity + IncrementalResult result = drawCalligraphyPathIncremental( + canvas, points, basePaint, prevLeftEdge, prevRightEdge, !hasPreviousEdge, initialHalfWidth); + + // Add end cap for visual feedback while drawing + // Use the rate-limited width from incremental result for consistency + if (points.size() >= 2 && result.lastHalfWidth > 0) { + std::vector smoothedPoints; + interpolateSplinePoints(points, smoothedPoints, true); + + if (smoothedPoints.size() >= 2) { + const Point& endPt = smoothedPoints.back(); + const Point& prevPt = smoothedPoints[smoothedPoints.size() - 2]; + + // Use rate-limited width from incremental render (not recalculated) + // This ensures the cap matches the stroke body exactly + float endHalfWidth = result.lastHalfWidth; + + float dx = endPt.x - prevPt.x; + float dy = endPt.y - prevPt.y; + float len = std::sqrt(dx * dx + dy * dy); + if (len > 0.001f) { dx /= len; dy /= len; } + + float perpX = -dy; + float perpY = dx; + + SkPoint endLeft = SkPoint::Make(endPt.x + perpX * endHalfWidth, endPt.y + perpY * endHalfWidth); + SkPoint endRight = SkPoint::Make(endPt.x - perpX * endHalfWidth, endPt.y - perpY * endHalfWidth); + SkPoint tipPoint = SkPoint::Make(endPt.x + dx * endHalfWidth * 0.8f, endPt.y + dy * endHalfWidth * 0.8f); + + SkPath endCapPath; + endCapPath.moveTo(endLeft); + endCapPath.quadTo(tipPoint, endRight); + endCapPath.close(); + + SkPaint fillPaint = basePaint; + fillPaint.setStyle(SkPaint::kFill_Style); + fillPaint.setAntiAlias(true); + + canvas->drawPath(endCapPath, fillPaint); + } + } +} + +} // namespace nativedrawing diff --git a/cpp/PathRendererCrayon.cpp b/cpp/PathRendererCrayon.cpp new file mode 100644 index 0000000..8903d5b --- /dev/null +++ b/cpp/PathRendererCrayon.cpp @@ -0,0 +1,611 @@ +#include "PathRenderer.h" + +#include +#include + +#include +#include +#include + +namespace nativedrawing { + +sk_sp PathRenderer::createCrayonShader(SkColor baseColor, float pressure, float width, float strokeAngle) { + // ===== CHECK SHADER CACHE FIRST ===== + uint64_t cacheKey = getShaderCacheKey(baseColor, pressure, strokeAngle); + auto cacheIt = shaderCache_.find(cacheKey); + if (cacheIt != shaderCache_.end()) { + return cacheIt->second; // Return cached shader - HUGE performance win! + } + + // ===== PENCILKIT-STYLE CRAYON TEXTURE ===== + // Key characteristics: + // 1. Very visible directional streaks (lined texture) + // 2. DRAMATIC pressure variance (light = almost nothing, heavy = solid) + // 3. Sharp on/off threshold (no mushy in-between) + + // ===== LAYER 1: STRONG DIRECTIONAL STREAKS ===== + // Extreme anisotropy for visible "lined" texture like PencilKit + float freqAlong = 0.008f; // Very low = long continuous streaks + float freqAcross = 0.35f; // Very high = fine perpendicular detail (44:1 ratio) + + sk_sp grainNoise = SkShaders::MakeTurbulence( + freqAlong, freqAcross, + 4, // High octaves for detail + 0.0f, + nullptr + ); + + // Rotate grain to follow stroke direction + SkMatrix rotationMatrix; + float angleDegrees = strokeAngle * (180.0f / 3.14159265f); + rotationMatrix.setRotate(angleDegrees); + sk_sp rotatedGrain = grainNoise->makeWithLocalMatrix(rotationMatrix); + + // ===== LAYER 2: PAPER GRAIN (finer) ===== + // Simulates paper texture that wax deposits on + sk_sp paperNoise = SkShaders::MakeTurbulence( + 0.12f, 0.12f, // Finer grain for paper texture + 3, + 42.0f, + nullptr + ); + + // ===== COMBINE: Multiply creates realistic wax-on-paper ===== + sk_sp combinedNoise = SkShaders::Blend( + SkBlendMode::kMultiply, + rotatedGrain, + paperNoise + ); + + // ===== DRAMATIC PRESSURE-CONTROLLED THRESHOLD ===== + // PencilKit has HUGE variance: light = barely visible, heavy = nearly solid + // + // Scale: Higher = sharper threshold (less gray, more black/white) + // Offset: Controls coverage (negative = sparse, positive = dense) + // + // Key: Light pressure must be REALLY light, solid only at heavy pressure + // Light pressure (0.1): offset=-1.05 -> extremely sparse + // Medium pressure (0.5): offset=-0.45 -> moderate coverage + // Heavy pressure (1.0): offset=0.3 -> solid + float scale = 1.5f + pressure * 1.0f; // 1.5 to 2.5 - moderate sharpness + float offset = -1.2f + pressure * 1.5f; // -1.2 to 0.3 - very gradual ramp + + float alphaMatrix[20] = { + 0, 0, 0, 0, SkColorGetR(baseColor) / 255.0f, + 0, 0, 0, 0, SkColorGetG(baseColor) / 255.0f, + 0, 0, 0, 0, SkColorGetB(baseColor) / 255.0f, + scale, scale, scale, 0, offset + }; + + sk_sp thresholdFilter = SkColorFilters::Matrix(alphaMatrix); + + sk_sp resultShader = combinedNoise->makeWithColorFilter(thresholdFilter); + + // Cache the shader for future reuse + shaderCache_[cacheKey] = resultShader; + + return resultShader; +} + +void PathRenderer::drawCrayonPath( + SkCanvas* canvas, + const std::vector& points, + const SkPaint& basePaint, + bool applyPressureAlpha +) { + if (points.empty() || points.size() < 2 || !canvas) return; + + SkColor baseColor = basePaint.getColor(); + float strokeWidth = basePaint.getStrokeWidth(); + + // Generate smoothed points with pressure using helper + std::vector smoothedPoints; + interpolateSplinePoints(points, smoothedPoints, true); + + // Build edges using helper + std::vector edges; + if (!buildEdgePoints(smoothedPoints, edges)) return; + + // ===== BATCH RENDERING WITH PER-POINT PRESSURE ===== + // OPTIMIZED: Increased batch size for 50% fewer shader lookups + // Texture noise masks batch boundaries, so larger batches are visually acceptable + constexpr int BATCH_SIZE = 24; + + for (size_t batchStart = 0; batchStart < edges.size() - 1; batchStart += BATCH_SIZE) { + size_t batchEnd = std::min(batchStart + BATCH_SIZE, edges.size() - 1); + + // Calculate batch pressure and angle (smooth local average) + float batchPressure = 0.0f; + float batchDx = 0.0f, batchDy = 0.0f; + for (size_t i = batchStart; i <= batchEnd; i++) { + batchPressure += edges[i].pressure; + batchDx += std::cos(edges[i].angle); + batchDy += std::sin(edges[i].angle); + } + batchPressure /= (batchEnd - batchStart + 1); + batchPressure = std::max(0.1f, std::min(1.0f, batchPressure)); + float batchAngle = std::atan2(batchDy, batchDx); + + // Create shader with this batch's pressure + sk_sp batchShader = createCrayonShader(baseColor, batchPressure, strokeWidth, batchAngle); + + SkPaint batchPaint; + batchPaint.setShader(batchShader); + batchPaint.setStyle(SkPaint::kFill_Style); + batchPaint.setAntiAlias(false); // No AA - let texture create rough edges + batchPaint.setAlpha(255); + + // Build path for this micro-batch + SkPath batchPath; + batchPath.moveTo(edges[batchStart].left); + + for (size_t i = batchStart + 1; i <= batchEnd; i++) { + batchPath.lineTo(edges[i].left); + } + + for (int i = static_cast(batchEnd); i >= static_cast(batchStart); i--) { + batchPath.lineTo(edges[i].right); + } + + batchPath.close(); + canvas->drawPath(batchPath, batchPaint); + } + + // ===== TEXTURED END CAPS ===== + // Draw rounded caps at start and end with the crayon texture + // This makes the tip look curved and textured, not a straight line + + if (smoothedPoints.size() >= 2) { + // END CAP - semicircle at the last point + size_t lastIdx = smoothedPoints.size() - 1; + const Point& endPt = smoothedPoints[lastIdx]; + float endHalfWidth = endPt.calculatedWidth / 2.0f; + + // Direction at end (from previous to current) + float endDx = endPt.x - smoothedPoints[lastIdx - 1].x; + float endDy = endPt.y - smoothedPoints[lastIdx - 1].y; + float endLen = std::sqrt(endDx * endDx + endDy * endDy); + if (endLen > 0.001f) { endDx /= endLen; endDy /= endLen; } + + // Create shader for end cap + float endAngle = std::atan2(endDy, endDx); + sk_sp endCapShader = createCrayonShader(baseColor, endPt.pressure, strokeWidth, endAngle); + + SkPaint endCapPaint; + endCapPaint.setShader(endCapShader); + endCapPaint.setStyle(SkPaint::kFill_Style); + endCapPaint.setAntiAlias(false); + endCapPaint.setAlpha(255); + + // Build semicircle path for end cap + // Arc from left edge to right edge, curving outward in stroke direction + SkPath endCapPath; + float perpX = -endDy; + float perpY = endDx; + + // Start at left edge point + SkPoint endLeft = SkPoint::Make(endPt.x + perpX * endHalfWidth, endPt.y + perpY * endHalfWidth); + SkPoint endRight = SkPoint::Make(endPt.x - perpX * endHalfWidth, endPt.y - perpY * endHalfWidth); + + endCapPath.moveTo(endLeft); + + // Arc sweep angle direction (from left to right via the tip) + float endArcAngle = std::atan2(perpY, perpX) * 180.0f / 3.14159265f; + SkRect endCapRect = SkRect::MakeXYWH( + endPt.x - endHalfWidth, + endPt.y - endHalfWidth, + endHalfWidth * 2.0f, + endHalfWidth * 2.0f); + endCapPath.arcTo(endCapRect, endArcAngle, -180.0f, false); + endCapPath.close(); + + canvas->drawPath(endCapPath, endCapPaint); + + // START CAP - semicircle at the first point + const Point& startPt = smoothedPoints[0]; + float startHalfWidth = startPt.calculatedWidth / 2.0f; + + // Direction at start (from current to next) + float startDx = smoothedPoints[1].x - startPt.x; + float startDy = smoothedPoints[1].y - startPt.y; + float startLen = std::sqrt(startDx * startDx + startDy * startDy); + if (startLen > 0.001f) { startDx /= startLen; startDy /= startLen; } + + // Create shader for start cap + float startAngle = std::atan2(startDy, startDx); + sk_sp startCapShader = createCrayonShader(baseColor, startPt.pressure, strokeWidth, startAngle); + + SkPaint startCapPaint; + startCapPaint.setShader(startCapShader); + startCapPaint.setStyle(SkPaint::kFill_Style); + startCapPaint.setAntiAlias(false); + startCapPaint.setAlpha(255); + + // Build semicircle path for start cap (curves backward) + SkPath startCapPath; + float startPerpX = -startDy; + float startPerpY = startDx; + + SkPoint startRight = SkPoint::Make(startPt.x - startPerpX * startHalfWidth, startPt.y - startPerpY * startHalfWidth); + + startCapPath.moveTo(startRight); + + float startArcAngle = std::atan2(-startPerpY, -startPerpX) * 180.0f / 3.14159265f; + SkRect startCapRect = SkRect::MakeXYWH( + startPt.x - startHalfWidth, + startPt.y - startHalfWidth, + startHalfWidth * 2.0f, + startHalfWidth * 2.0f); + startCapPath.arcTo(startCapRect, startArcAngle, -180.0f, false); + startCapPath.close(); + + canvas->drawPath(startCapPath, startCapPaint); + } +} + +void PathRenderer::drawCrayonEndCaps( + SkCanvas* canvas, + const std::vector& points, + const SkPaint& basePaint +) { + if (points.empty() || points.size() < 2 || !canvas) return; + + SkColor baseColor = basePaint.getColor(); + float strokeWidth = basePaint.getStrokeWidth(); + + // Generate smoothed points + std::vector smoothedPoints; + interpolateSplinePoints(points, smoothedPoints, true); + + if (smoothedPoints.size() < 2) return; + + // ===== END CAP - semicircle at the last point ===== + size_t lastIdx = smoothedPoints.size() - 1; + const Point& endPt = smoothedPoints[lastIdx]; + float endHalfWidth = endPt.calculatedWidth / 2.0f; + + float endDx = endPt.x - smoothedPoints[lastIdx - 1].x; + float endDy = endPt.y - smoothedPoints[lastIdx - 1].y; + float endLen = std::sqrt(endDx * endDx + endDy * endDy); + if (endLen > 0.001f) { endDx /= endLen; endDy /= endLen; } + + float endAngle = std::atan2(endDy, endDx); + sk_sp endCapShader = createCrayonShader(baseColor, endPt.pressure, strokeWidth, endAngle); + + SkPaint endCapPaint; + endCapPaint.setShader(endCapShader); + endCapPaint.setStyle(SkPaint::kFill_Style); + endCapPaint.setAntiAlias(false); + endCapPaint.setAlpha(255); + + SkPath endCapPath; + float perpX = -endDy; + float perpY = endDx; + + SkPoint endLeft = SkPoint::Make(endPt.x + perpX * endHalfWidth, endPt.y + perpY * endHalfWidth); + endCapPath.moveTo(endLeft); + + float endArcAngle = std::atan2(perpY, perpX) * 180.0f / 3.14159265f; + SkRect endCapRect = SkRect::MakeXYWH( + endPt.x - endHalfWidth, + endPt.y - endHalfWidth, + endHalfWidth * 2.0f, + endHalfWidth * 2.0f); + endCapPath.arcTo(endCapRect, endArcAngle, -180.0f, false); + endCapPath.close(); + + canvas->drawPath(endCapPath, endCapPaint); + + // ===== START CAP - semicircle at the first point ===== + const Point& startPt = smoothedPoints[0]; + float startHalfWidth = startPt.calculatedWidth / 2.0f; + + float startDx = smoothedPoints[1].x - startPt.x; + float startDy = smoothedPoints[1].y - startPt.y; + float startLen = std::sqrt(startDx * startDx + startDy * startDy); + if (startLen > 0.001f) { startDx /= startLen; startDy /= startLen; } + + float startAngle = std::atan2(startDy, startDx); + sk_sp startCapShader = createCrayonShader(baseColor, startPt.pressure, strokeWidth, startAngle); + + SkPaint startCapPaint; + startCapPaint.setShader(startCapShader); + startCapPaint.setStyle(SkPaint::kFill_Style); + startCapPaint.setAntiAlias(false); + startCapPaint.setAlpha(255); + + SkPath startCapPath; + float startPerpX = -startDy; + float startPerpY = startDx; + + SkPoint startRight = SkPoint::Make(startPt.x - startPerpX * startHalfWidth, startPt.y - startPerpY * startHalfWidth); + startCapPath.moveTo(startRight); + + float startArcAngle = std::atan2(-startPerpY, -startPerpX) * 180.0f / 3.14159265f; + SkRect startCapRect = SkRect::MakeXYWH( + startPt.x - startHalfWidth, + startPt.y - startHalfWidth, + startHalfWidth * 2.0f, + startHalfWidth * 2.0f); + startCapPath.arcTo(startCapRect, startArcAngle, -180.0f, false); + startCapPath.close(); + + canvas->drawPath(startCapPath, startCapPaint); +} + +void PathRenderer::drawCrayonStartCap( + SkCanvas* canvas, + const std::vector& points, + const SkPaint& basePaint +) { + if (points.empty() || points.size() < 2 || !canvas) return; + + SkColor baseColor = basePaint.getColor(); + float strokeWidth = basePaint.getStrokeWidth(); + + // Generate smoothed points + std::vector smoothedPoints; + interpolateSplinePoints(points, smoothedPoints, true); + + if (smoothedPoints.size() < 2) return; + + // START CAP - semicircle at the first point + const Point& startPt = smoothedPoints[0]; + float startHalfWidth = startPt.calculatedWidth / 2.0f; + + float startDx = smoothedPoints[1].x - startPt.x; + float startDy = smoothedPoints[1].y - startPt.y; + float startLen = std::sqrt(startDx * startDx + startDy * startDy); + if (startLen > 0.001f) { startDx /= startLen; startDy /= startLen; } + + float startAngle = std::atan2(startDy, startDx); + sk_sp startCapShader = createCrayonShader(baseColor, startPt.pressure, strokeWidth, startAngle); + + SkPaint startCapPaint; + startCapPaint.setShader(startCapShader); + startCapPaint.setStyle(SkPaint::kFill_Style); + startCapPaint.setAntiAlias(false); + startCapPaint.setAlpha(255); + + SkPath startCapPath; + float startPerpX = -startDy; + float startPerpY = startDx; + + SkPoint startRight = SkPoint::Make(startPt.x - startPerpX * startHalfWidth, startPt.y - startPerpY * startHalfWidth); + startCapPath.moveTo(startRight); + + float startArcAngle = std::atan2(-startPerpY, -startPerpX) * 180.0f / 3.14159265f; + SkRect startCapRect = SkRect::MakeXYWH( + startPt.x - startHalfWidth, + startPt.y - startHalfWidth, + startHalfWidth * 2.0f, + startHalfWidth * 2.0f); + startCapPath.arcTo(startCapRect, startArcAngle, -180.0f, false); + startCapPath.close(); + + canvas->drawPath(startCapPath, startCapPaint); +} + +void PathRenderer::drawCrayonEndCap( + SkCanvas* canvas, + const std::vector& points, + const SkPaint& basePaint +) { + if (points.empty() || points.size() < 2 || !canvas) return; + + SkColor baseColor = basePaint.getColor(); + float strokeWidth = basePaint.getStrokeWidth(); + + // Generate smoothed points + std::vector smoothedPoints; + interpolateSplinePoints(points, smoothedPoints, true); + + if (smoothedPoints.size() < 2) return; + + // END CAP - semicircle at the last point + size_t lastIdx = smoothedPoints.size() - 1; + const Point& endPt = smoothedPoints[lastIdx]; + float endHalfWidth = endPt.calculatedWidth / 2.0f; + + float endDx = endPt.x - smoothedPoints[lastIdx - 1].x; + float endDy = endPt.y - smoothedPoints[lastIdx - 1].y; + float endLen = std::sqrt(endDx * endDx + endDy * endDy); + if (endLen > 0.001f) { endDx /= endLen; endDy /= endLen; } + + float endAngle = std::atan2(endDy, endDx); + sk_sp endCapShader = createCrayonShader(baseColor, endPt.pressure, strokeWidth, endAngle); + + SkPaint endCapPaint; + endCapPaint.setShader(endCapShader); + endCapPaint.setStyle(SkPaint::kFill_Style); + endCapPaint.setAntiAlias(false); + endCapPaint.setAlpha(255); + + SkPath endCapPath; + float perpX = -endDy; + float perpY = endDx; + + SkPoint endLeft = SkPoint::Make(endPt.x + perpX * endHalfWidth, endPt.y + perpY * endHalfWidth); + endCapPath.moveTo(endLeft); + + float endArcAngle = std::atan2(perpY, perpX) * 180.0f / 3.14159265f; + SkRect endCapRect = SkRect::MakeXYWH( + endPt.x - endHalfWidth, + endPt.y - endHalfWidth, + endHalfWidth * 2.0f, + endHalfWidth * 2.0f); + endCapPath.arcTo(endCapRect, endArcAngle, -180.0f, false); + endCapPath.close(); + + canvas->drawPath(endCapPath, endCapPaint); +} + +// ===== SHADER CACHING IMPLEMENTATION ===== +// OPTIMIZED: Reduced quantization levels for 5x higher cache hit rate +// 8 pressure x 8 angle = 64 slots vs 320 before (perceptually equivalent for noise textures) + +uint64_t PathRenderer::getShaderCacheKey(SkColor color, float pressure, float angle) { + // Quantize pressure to 8 levels (3 bits) - sufficient for noise texture variance + int pressureLevel = static_cast(std::max(0.0f, std::min(1.0f, pressure)) * 7.99f); + + // Quantize angle to 8 buckets (3 bits) - 45 degrees each + // Normalize angle to 0-2PI range first + float normalizedAngle = angle; + while (normalizedAngle < 0) normalizedAngle += 2.0f * 3.14159265f; + while (normalizedAngle >= 2.0f * 3.14159265f) normalizedAngle -= 2.0f * 3.14159265f; + int angleBucket = static_cast(normalizedAngle / (2.0f * 3.14159265f) * 8) % 8; + + // Combine: color (32 bits) | pressure (3 bits) | angle (3 bits) + return (static_cast(color) << 6) | + (static_cast(pressureLevel) << 3) | + static_cast(angleBucket); +} + +void PathRenderer::clearShaderCache() { + shaderCache_.clear(); +} + +IncrementalResult PathRenderer::drawCrayonPathIncremental( + SkCanvas* canvas, + const std::vector& points, + const SkPaint& basePaint, + const SkPoint& prevLeftEdge, + const SkPoint& prevRightEdge, + bool isFirstSegment +) { + IncrementalResult result = {}; + if (points.empty() || points.size() < 2 || !canvas) return result; + + SkColor baseColor = basePaint.getColor(); + float strokeWidth = basePaint.getStrokeWidth(); + + // Generate smoothed points using helper + std::vector smoothedPoints; + interpolateSplinePoints( + points, + smoothedPoints, + true, + LIVE_SEGMENTS_PER_SPAN, + LIVE_SPLINE_TENSION + ); + + // Build edges using helper + std::vector edges; + if (!buildEdgePoints(smoothedPoints, edges)) return result; + + // Connect to previous segment (replace first edge with previous edge) + if (!isFirstSegment) { + edges[0].left = prevLeftEdge; + edges[0].right = prevRightEdge; + } + + // Batch rendering (same as drawCrayonPath) + constexpr int BATCH_SIZE = 24; + for (size_t batchStart = 0; batchStart < edges.size() - 1; batchStart += BATCH_SIZE) { + size_t batchEnd = std::min(batchStart + BATCH_SIZE, edges.size() - 1); + + float batchPressure = 0.0f; + float batchDx = 0.0f, batchDy = 0.0f; + for (size_t i = batchStart; i <= batchEnd; i++) { + batchPressure += edges[i].pressure; + batchDx += std::cos(edges[i].angle); + batchDy += std::sin(edges[i].angle); + } + batchPressure = std::max(0.1f, std::min(1.0f, batchPressure / (batchEnd - batchStart + 1))); + float batchAngle = std::atan2(batchDy, batchDx); + + sk_sp batchShader = createCrayonShader(baseColor, batchPressure, strokeWidth, batchAngle); + + SkPaint batchPaint; + batchPaint.setShader(batchShader); + batchPaint.setStyle(SkPaint::kFill_Style); + batchPaint.setAntiAlias(false); + batchPaint.setAlpha(255); + + SkPath batchPath; + batchPath.moveTo(edges[batchStart].left); + for (size_t i = batchStart + 1; i <= batchEnd; i++) { + batchPath.lineTo(edges[i].left); + } + for (int i = static_cast(batchEnd); i >= static_cast(batchStart); i--) { + batchPath.lineTo(edges[i].right); + } + batchPath.close(); + canvas->drawPath(batchPath, batchPaint); + } + + // Return last edge for next segment connection + result.lastLeftEdge = edges.back().left; + result.lastRightEdge = edges.back().right; + result.lastPressure = edges.back().pressure; + result.lastAngle = edges.back().angle; + result.smoothedPointsRendered = smoothedPoints.size(); + + return result; +} + +void PathRenderer::drawCrayonPathTail( + SkCanvas* canvas, + const std::vector& points, + const SkPaint& basePaint, + const SkPoint& prevLeftEdge, + const SkPoint& prevRightEdge, + bool hasPreviousEdge +) { + // Tail is the recent points showing the current pen position + // We render it with an end cap so the tip looks rounded while drawing + if (points.size() < 2) return; + + // First, draw the body using incremental rendering + IncrementalResult result = drawCrayonPathIncremental( + canvas, points, basePaint, prevLeftEdge, prevRightEdge, !hasPreviousEdge); + + // Add end cap to the tail tip (so user sees rounded tip while drawing) + SkColor baseColor = basePaint.getColor(); + float strokeWidth = basePaint.getStrokeWidth(); + + // Generate smoothed points to get the last point position + std::vector smoothedPoints; + interpolateSplinePoints(points, smoothedPoints, true); + + if (smoothedPoints.size() >= 2) { + // Draw textured end cap at the tip + size_t lastIdx = smoothedPoints.size() - 1; + const Point& endPt = smoothedPoints[lastIdx]; + float endHalfWidth = endPt.calculatedWidth / 2.0f; + + float endDx = endPt.x - smoothedPoints[lastIdx - 1].x; + float endDy = endPt.y - smoothedPoints[lastIdx - 1].y; + float endLen = std::sqrt(endDx * endDx + endDy * endDy); + if (endLen > 0.001f) { endDx /= endLen; endDy /= endLen; } + + float endAngle = std::atan2(endDy, endDx); + sk_sp endCapShader = createCrayonShader(baseColor, endPt.pressure, strokeWidth, endAngle); + + SkPaint endCapPaint; + endCapPaint.setShader(endCapShader); + endCapPaint.setStyle(SkPaint::kFill_Style); + endCapPaint.setAntiAlias(false); + endCapPaint.setAlpha(255); + + SkPath endCapPath; + float perpX = -endDy; + float perpY = endDx; + + SkPoint endLeft = SkPoint::Make(endPt.x + perpX * endHalfWidth, endPt.y + perpY * endHalfWidth); + endCapPath.moveTo(endLeft); + + float endArcAngle = std::atan2(perpY, perpX) * 180.0f / 3.14159265f; + SkRect endCapRect = SkRect::MakeXYWH( + endPt.x - endHalfWidth, + endPt.y - endHalfWidth, + endHalfWidth * 2.0f, + endHalfWidth * 2.0f); + endCapPath.arcTo(endCapRect, endArcAngle, -180.0f, false); + endCapPath.close(); + + canvas->drawPath(endCapPath, endCapPaint); + } +} + +} // namespace nativedrawing diff --git a/cpp/ShapeRecognition.cpp b/cpp/ShapeRecognition.cpp new file mode 100644 index 0000000..aa89bc2 --- /dev/null +++ b/cpp/ShapeRecognition.cpp @@ -0,0 +1,1055 @@ +#include "ShapeRecognition.h" +#include +#include +#include + +namespace nativedrawing { + +constexpr long kHoldToShapeDurationMs = 300; +constexpr float kMinimumShapeDiagonal = 30.0f; +constexpr float kPi = 3.14159265358979323846f; + +bool isRecognizedShapeToolType(const std::string& toolType) { + return toolType == "shape-line" + || toolType == "shape-rectangle" + || toolType == "shape-circle" + || toolType == "shape-ellipse" + || toolType == "shape-polygon"; +} + +bool buildRecognizedShapePath( + const std::string& toolType, + const std::vector& points, + SkPath& path +) { + if (!isRecognizedShapeToolType(toolType) || points.size() < 2) { + return false; + } + + path.reset(); + + if (toolType == "shape-line") { + path.moveTo(points.front().x, points.front().y); + path.lineTo(points.back().x, points.back().y); + return true; + } + + float minX = points.front().x; + float maxX = points.front().x; + float minY = points.front().y; + float maxY = points.front().y; + + for (const auto& point : points) { + minX = std::min(minX, point.x); + maxX = std::max(maxX, point.x); + minY = std::min(minY, point.y); + maxY = std::max(maxY, point.y); + } + + if (std::fabs(maxX - minX) < 0.001f || std::fabs(maxY - minY) < 0.001f) { + return false; + } + + const SkRect bounds = SkRect::MakeLTRB(minX, minY, maxX, maxY); + + if (toolType == "shape-polygon" || (toolType == "shape-rectangle" && points.size() >= 4)) { + if (points.size() < 3) { + return false; + } + + path.moveTo(points.front().x, points.front().y); + for (size_t i = 1; i < points.size(); ++i) { + path.lineTo(points[i].x, points[i].y); + } + path.close(); + return true; + } + + if ((toolType == "shape-circle" || toolType == "shape-ellipse") && points.size() >= 6) { + path.moveTo(points.front().x, points.front().y); + for (size_t i = 1; i < points.size(); ++i) { + path.lineTo(points[i].x, points[i].y); + } + path.close(); + return true; + } + + if (toolType == "shape-rectangle") { + path.addRect(bounds); + return true; + } + + if (toolType == "shape-circle" || toolType == "shape-ellipse") { + path.addOval(bounds); + return true; + } + + return false; +} + +bool canRecognizeShapeForTool(const std::string& toolType) { + return toolType == "pen" + || toolType == "pencil" + || toolType == "marker" + || toolType == "highlighter" + || toolType == "crayon" + || toolType == "calligraphy"; +} + +float distanceBetween(float x1, float y1, float x2, float y2) { + const float dx = x2 - x1; + const float dy = y2 - y1; + return std::sqrt(dx * dx + dy * dy); +} + +float distanceBetween(const Point& a, const Point& b) { + return distanceBetween(a.x, a.y, b.x, b.y); +} + +float averageCalculatedWidth(const std::vector& points) { + if (points.empty()) { + return 1.0f; + } + + float total = 0.0f; + for (const auto& point : points) { + total += std::max(0.5f, point.calculatedWidth); + } + return total / static_cast(points.size()); +} + +float averagePressure(const std::vector& points) { + if (points.empty()) { + return 1.0f; + } + + float total = 0.0f; + for (const auto& point : points) { + total += std::max(0.1f, std::min(1.0f, point.pressure)); + } + return total / static_cast(points.size()); +} + +float pathLength(const std::vector& points) { + if (points.size() < 2) { + return 0.0f; + } + + float total = 0.0f; + for (size_t i = 1; i < points.size(); ++i) { + total += distanceBetween(points[i - 1], points[i]); + } + return total; +} + +SkRect boundsForPoints(const std::vector& points) { + if (points.empty()) { + return SkRect::MakeEmpty(); + } + + float minX = points.front().x; + float maxX = points.front().x; + float minY = points.front().y; + float maxY = points.front().y; + + for (const auto& point : points) { + minX = std::min(minX, point.x); + maxX = std::max(maxX, point.x); + minY = std::min(minY, point.y); + maxY = std::max(maxY, point.y); + } + + return SkRect::MakeLTRB(minX, minY, maxX, maxY); +} + +struct ShapePointStyle { + float pressure = 1.0f; + float azimuthAngle = 0.0f; + float altitude = 1.57f; + float calculatedWidth = 1.0f; + long timestamp = 0; +}; + +ShapePointStyle styleForShapePoints(const std::vector& points) { + ShapePointStyle style; + if (points.empty()) { + return style; + } + + style.pressure = averagePressure(points); + style.calculatedWidth = averageCalculatedWidth(points); + style.azimuthAngle = points.back().azimuthAngle; + style.altitude = points.back().altitude; + style.timestamp = points.back().timestamp; + return style; +} + +Point makeShapePoint(float x, float y, const ShapePointStyle& style) { + Point point; + point.x = x; + point.y = y; + point.pressure = style.pressure; + point.azimuthAngle = style.azimuthAngle; + point.altitude = style.altitude; + point.calculatedWidth = style.calculatedWidth; + point.timestamp = style.timestamp; + return point; +} + +void appendLinePoints( + std::vector& output, + float x1, + float y1, + float x2, + float y2, + const ShapePointStyle& style, + float spacing, + bool includeStart +) { + const float length = distanceBetween(x1, y1, x2, y2); + const int steps = std::max(1, static_cast(std::ceil(length / std::max(2.0f, spacing)))); + const int startStep = includeStart ? 0 : 1; + + for (int step = startStep; step <= steps; ++step) { + const float t = static_cast(step) / static_cast(steps); + output.push_back(makeShapePoint( + x1 + (x2 - x1) * t, + y1 + (y2 - y1) * t, + style + )); + } +} + + +ShapeCandidate makeLineCandidate(const std::vector& points) { + ShapeCandidate candidate; + candidate.recognized = true; + candidate.toolType = "shape-line"; + const ShapePointStyle style = styleForShapePoints(points); + candidate.points.push_back(makeShapePoint(points.front().x, points.front().y, style)); + candidate.points.push_back(makeShapePoint(points.back().x, points.back().y, style)); + return candidate; +} + +ShapeCandidate makeRectangleCandidate(const std::vector& points, const SkRect& bounds) { + ShapeCandidate candidate; + candidate.recognized = true; + candidate.toolType = "shape-rectangle"; + + const ShapePointStyle style = styleForShapePoints(points); + const float spacing = std::max(4.0f, style.calculatedWidth * 1.6f); + + appendLinePoints(candidate.points, bounds.left(), bounds.top(), bounds.right(), bounds.top(), style, spacing, true); + appendLinePoints(candidate.points, bounds.right(), bounds.top(), bounds.right(), bounds.bottom(), style, spacing, false); + appendLinePoints(candidate.points, bounds.right(), bounds.bottom(), bounds.left(), bounds.bottom(), style, spacing, false); + appendLinePoints(candidate.points, bounds.left(), bounds.bottom(), bounds.left(), bounds.top(), style, spacing, false); + + return candidate; +} + +ShapeCandidate makeEllipseCandidate(const std::vector& points, const SkRect& bounds) { + ShapeCandidate candidate; + candidate.recognized = true; + + const ShapePointStyle style = styleForShapePoints(points); + const float width = bounds.width(); + const float height = bounds.height(); + const float aspectError = std::fabs(width - height) / std::max(width, height); + const bool shouldForceCircle = aspectError <= 0.18f; + + float centerX = bounds.centerX(); + float centerY = bounds.centerY(); + float radiusX = width / 2.0f; + float radiusY = height / 2.0f; + + if (shouldForceCircle) { + const float radius = (radiusX + radiusY) / 2.0f; + radiusX = radius; + radiusY = radius; + candidate.toolType = "shape-circle"; + } else { + candidate.toolType = "shape-ellipse"; + } + + const float circumferenceEstimate = 2.0f * kPi * std::sqrt((radiusX * radiusX + radiusY * radiusY) / 2.0f); + const int segments = std::max(40, std::min(128, static_cast(circumferenceEstimate / std::max(3.0f, style.calculatedWidth * 1.4f)))); + + candidate.points.reserve(static_cast(segments) + 1); + for (int i = 0; i <= segments; ++i) { + const float theta = (2.0f * kPi * static_cast(i)) / static_cast(segments); + candidate.points.push_back(makeShapePoint( + centerX + std::cos(theta) * radiusX, + centerY + std::sin(theta) * radiusY, + style + )); + } + + return candidate; +} + +std::vector transformedShapePoints( + const std::vector& basePoints, + const Point& anchor, + float referenceAngle, + float referenceDistance, + float targetX, + float targetY, + float scaleSensitivity, + float rotationSensitivity +) { + if (basePoints.empty()) { + return {}; + } + + const float targetDx = targetX - anchor.x; + const float targetDy = targetY - anchor.y; + const float targetDistance = std::sqrt(targetDx * targetDx + targetDy * targetDy); + if (targetDistance < 0.001f || referenceDistance < 0.001f) { + return basePoints; + } + + const float rawScale = targetDistance / referenceDistance; + const float scale = std::max( + 0.08f, + 1.0f + (rawScale - 1.0f) * std::max(0.0f, std::min(1.0f, scaleSensitivity)) + ); + const float rawRotation = std::atan2(targetDy, targetDx) - referenceAngle; + const float rotation = rawRotation * std::max(0.0f, std::min(1.0f, rotationSensitivity)); + const float cosRotation = std::cos(rotation); + const float sinRotation = std::sin(rotation); + + std::vector transformed; + transformed.reserve(basePoints.size()); + for (const auto& point : basePoints) { + const float localX = (point.x - anchor.x) * scale; + const float localY = (point.y - anchor.y) * scale; + Point next = point; + next.x = anchor.x + (localX * cosRotation - localY * sinRotation); + next.y = anchor.y + (localX * sinRotation + localY * cosRotation); + transformed.push_back(next); + } + + return transformed; +} + +Point centerPointForPoints(const std::vector& points) { + if (points.empty()) { + return makeShapePoint(0.0f, 0.0f, ShapePointStyle{}); + } + + float minX = points.front().x; + float maxX = points.front().x; + float minY = points.front().y; + float maxY = points.front().y; + + for (const auto& point : points) { + minX = std::min(minX, point.x); + maxX = std::max(maxX, point.x); + minY = std::min(minY, point.y); + maxY = std::max(maxY, point.y); + } + + Point center = points.front(); + center.x = (minX + maxX) * 0.5f; + center.y = (minY + maxY) * 0.5f; + return center; +} + +float clampedSignedScale(float rawScale) { + if (!std::isfinite(rawScale)) { + return 1.0f; + } + + const float magnitude = std::max(0.08f, std::fabs(rawScale)); + return rawScale < 0.0f ? -magnitude : magnitude; +} + +std::vector transformedShapePointsCenterLockedToTarget( + const std::vector& basePoints, + const Point& center, + float referenceAngle, + float referenceDistance, + float targetX, + float targetY +) { + if (basePoints.empty()) { + return {}; + } + + const float refDx = std::cos(referenceAngle) * referenceDistance; + const float refDy = std::sin(referenceAngle) * referenceDistance; + const float targetDx = targetX - center.x; + const float targetDy = targetY - center.y; + const float targetDistance = std::sqrt(targetDx * targetDx + targetDy * targetDy); + const float fallbackScale = referenceDistance > 0.001f + ? targetDistance / referenceDistance + : 1.0f; + + const float scaleX = std::fabs(refDx) > 2.0f + ? clampedSignedScale(targetDx / refDx) + : clampedSignedScale(fallbackScale); + const float scaleY = std::fabs(refDy) > 2.0f + ? clampedSignedScale(targetDy / refDy) + : clampedSignedScale(fallbackScale); + + std::vector transformed; + transformed.reserve(basePoints.size()); + for (const auto& point : basePoints) { + Point next = point; + next.x = center.x + ((point.x - center.x) * scaleX); + next.y = center.y + ((point.y - center.y) * scaleY); + transformed.push_back(next); + } + + return transformed; +} + +float distanceFromPointToSegment(const Point& point, const Point& start, const Point& end) { + const float dx = end.x - start.x; + const float dy = end.y - start.y; + const float lengthSq = dx * dx + dy * dy; + + if (lengthSq < 0.001f) { + return distanceBetween(point, start); + } + + const float t = std::max(0.0f, std::min( + 1.0f, + ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSq + )); + + return distanceBetween( + point.x, + point.y, + start.x + (dx * t), + start.y + (dy * t) + ); +} + +void simplifyPolylineRecursive( + const std::vector& points, + size_t first, + size_t last, + float epsilon, + std::vector& outputIndices +) { + if (last <= first + 1) { + return; + } + + float maxDistance = 0.0f; + size_t maxIndex = first; + for (size_t i = first + 1; i < last; ++i) { + const float distance = distanceFromPointToSegment(points[i], points[first], points[last]); + if (distance > maxDistance) { + maxDistance = distance; + maxIndex = i; + } + } + + if (maxDistance <= epsilon || maxIndex == first) { + return; + } + + simplifyPolylineRecursive(points, first, maxIndex, epsilon, outputIndices); + outputIndices.push_back(maxIndex); + simplifyPolylineRecursive(points, maxIndex, last, epsilon, outputIndices); +} + +std::vector decimatedShapePoints(const std::vector& points, float minimumSpacing) { + std::vector decimated; + decimated.reserve(points.size()); + + for (const auto& point : points) { + if (decimated.empty() || distanceBetween(decimated.back(), point) >= minimumSpacing) { + decimated.push_back(point); + } + } + + if (decimated.size() > 3 && distanceBetween(decimated.front(), decimated.back()) < minimumSpacing) { + decimated.pop_back(); + } + + return decimated; +} + +std::vector simplifyPolyline(const std::vector& points, float epsilon) { + if (points.size() <= 2) { + return points; + } + + std::vector indices; + indices.reserve(points.size()); + indices.push_back(0); + simplifyPolylineRecursive(points, 0, points.size() - 1, epsilon, indices); + indices.push_back(points.size() - 1); + std::sort(indices.begin(), indices.end()); + indices.erase(std::unique(indices.begin(), indices.end()), indices.end()); + + std::vector simplified; + simplified.reserve(indices.size()); + for (size_t index : indices) { + simplified.push_back(points[index]); + } + + return simplified; +} + +std::vector removeNearlyCollinearVertices( + const std::vector& vertices, + float tolerance +) { + if (vertices.size() <= 3) { + return vertices; + } + + std::vector pruned = vertices; + bool removed = true; + while (removed && pruned.size() > 3) { + removed = false; + for (size_t i = 0; i < pruned.size(); ++i) { + const Point& previous = pruned[(i + pruned.size() - 1) % pruned.size()]; + const Point& current = pruned[i]; + const Point& next = pruned[(i + 1) % pruned.size()]; + + if (distanceFromPointToSegment(current, previous, next) <= tolerance) { + pruned.erase(pruned.begin() + static_cast(i)); + removed = true; + break; + } + } + } + + return pruned; +} + +float polygonFitMeanError( + const std::vector& sourcePoints, + const std::vector& vertices, + float* maxErrorOut +) { + if (vertices.size() < 3 || sourcePoints.empty()) { + if (maxErrorOut) { + *maxErrorOut = std::numeric_limits::max(); + } + return std::numeric_limits::max(); + } + + float totalError = 0.0f; + float maxError = 0.0f; + for (const auto& sourcePoint : sourcePoints) { + float nearestDistance = std::numeric_limits::max(); + for (size_t i = 0; i < vertices.size(); ++i) { + const Point& start = vertices[i]; + const Point& end = vertices[(i + 1) % vertices.size()]; + nearestDistance = std::min( + nearestDistance, + distanceFromPointToSegment(sourcePoint, start, end) + ); + } + totalError += nearestDistance; + maxError = std::max(maxError, nearestDistance); + } + + if (maxErrorOut) { + *maxErrorOut = maxError; + } + return totalError / static_cast(sourcePoints.size()); +} + +struct PolygonRoughFit { + size_t vertexCount = 0; + float meanError = std::numeric_limits::max(); + float maxError = std::numeric_limits::max(); +}; + +PolygonRoughFit roughPolygonFitForStroke( + const std::vector& points, + const SkRect& bounds, + float averageWidth +) { + PolygonRoughFit fit; + + const float diagonal = std::sqrt(bounds.width() * bounds.width() + bounds.height() * bounds.height()); + const float decimationSpacing = std::max(3.0f, averageWidth * 1.2f); + const std::vector sourcePoints = decimatedShapePoints(points, decimationSpacing); + if (sourcePoints.size() < 6) { + return fit; + } + + const float epsilon = std::max(8.0f, std::max(averageWidth * 2.2f, diagonal * 0.028f)); + std::vector vertices = simplifyPolyline(sourcePoints, epsilon); + if (vertices.size() > 3 && distanceBetween(vertices.front(), vertices.back()) <= std::max(12.0f, averageWidth * 3.0f)) { + vertices.pop_back(); + } + vertices = removeNearlyCollinearVertices(vertices, std::max(5.0f, averageWidth * 1.7f)); + + fit.vertexCount = vertices.size(); + if (vertices.size() >= 3) { + fit.meanError = polygonFitMeanError(sourcePoints, vertices, &fit.maxError); + } + + return fit; +} + +std::vector reducePolygonVerticesByFit( + const std::vector& sourcePoints, + const std::vector& vertices, + float averageWidth, + float diagonal +) { + if (vertices.size() <= 3) { + return vertices; + } + + const float meanTolerance = std::max(10.0f, std::max(averageWidth * 2.8f, diagonal * 0.045f)); + const float maxTolerance = std::max(24.0f, std::max(averageWidth * 5.0f, diagonal * 0.14f)); + std::vector reduced = vertices; + + while (reduced.size() > 3) { + size_t bestRemoveIndex = std::numeric_limits::max(); + float bestScore = std::numeric_limits::max(); + std::vector bestVertices; + + for (size_t i = 0; i < reduced.size(); ++i) { + std::vector trial; + trial.reserve(reduced.size() - 1); + for (size_t j = 0; j < reduced.size(); ++j) { + if (j != i) { + trial.push_back(reduced[j]); + } + } + + float maxError = 0.0f; + const float meanError = polygonFitMeanError(sourcePoints, trial, &maxError); + if (meanError > meanTolerance || maxError > maxTolerance) { + continue; + } + + const float score = meanError + maxError * 0.08f; + if (score < bestScore) { + bestScore = score; + bestRemoveIndex = i; + bestVertices = std::move(trial); + } + } + + if (bestRemoveIndex == std::numeric_limits::max()) { + break; + } + + reduced = std::move(bestVertices); + } + + return reduced; +} + +ShapeCandidate makePolygonCandidate( + const std::vector& points, + const SkRect& bounds, + float averageWidth +) { + ShapeCandidate candidate; + + const float diagonal = std::sqrt(bounds.width() * bounds.width() + bounds.height() * bounds.height()); + const float decimationSpacing = std::max(3.0f, averageWidth * 1.2f); + const std::vector sourcePoints = decimatedShapePoints(points, decimationSpacing); + if (sourcePoints.size() < 6) { + return candidate; + } + + const float epsilon = std::max(8.0f, std::max(averageWidth * 2.2f, diagonal * 0.028f)); + std::vector vertices = simplifyPolyline(sourcePoints, epsilon); + if (vertices.size() > 3 && distanceBetween(vertices.front(), vertices.back()) <= std::max(12.0f, averageWidth * 3.0f)) { + vertices.pop_back(); + } + vertices = removeNearlyCollinearVertices(vertices, std::max(5.0f, averageWidth * 1.7f)); + vertices = reducePolygonVerticesByFit(sourcePoints, vertices, averageWidth, diagonal); + vertices = removeNearlyCollinearVertices(vertices, std::max(5.0f, averageWidth * 1.7f)); + + if (vertices.size() < 3 || vertices.size() > 12) { + return candidate; + } + + float maxError = 0.0f; + const float meanError = polygonFitMeanError(sourcePoints, vertices, &maxError); + const float meanTolerance = std::max(10.0f, std::max(averageWidth * 2.8f, diagonal * 0.045f)); + const float maxTolerance = std::max(24.0f, std::max(averageWidth * 5.0f, diagonal * 0.14f)); + if (meanError > meanTolerance || maxError > maxTolerance) { + return candidate; + } + + const ShapePointStyle style = styleForShapePoints(points); + candidate.recognized = true; + candidate.toolType = "shape-polygon"; + candidate.points.reserve(vertices.size()); + for (const auto& vertex : vertices) { + candidate.points.push_back(makeShapePoint(vertex.x, vertex.y, style)); + } + return candidate; +} + +bool appendUniqueSnapPoint(std::vector& output, const Point& point, float minimumDistance) { + for (const auto& existing : output) { + if (distanceBetween(existing, point) <= minimumDistance) { + return false; + } + } + + output.push_back(point); + return true; +} + +void collectSnapPointsForStroke(const Stroke& stroke, std::vector& output) { + if (stroke.points.empty() || stroke.isEraser) { + return; + } + + const float minimumDistance = std::max(8.0f, averageCalculatedWidth(stroke.points) * 1.5f); + + if (stroke.toolType == "shape-line") { + appendUniqueSnapPoint(output, stroke.points.front(), minimumDistance); + appendUniqueSnapPoint(output, stroke.points.back(), minimumDistance); + return; + } + + if (stroke.toolType == "shape-polygon") { + for (const auto& point : stroke.points) { + appendUniqueSnapPoint(output, point, minimumDistance); + } + return; + } + + if (stroke.toolType == "shape-rectangle") { + const SkRect bounds = boundsForPoints(stroke.points); + const ShapePointStyle style = styleForShapePoints(stroke.points); + appendUniqueSnapPoint(output, makeShapePoint(bounds.left(), bounds.top(), style), minimumDistance); + appendUniqueSnapPoint(output, makeShapePoint(bounds.right(), bounds.top(), style), minimumDistance); + appendUniqueSnapPoint(output, makeShapePoint(bounds.right(), bounds.bottom(), style), minimumDistance); + appendUniqueSnapPoint(output, makeShapePoint(bounds.left(), bounds.bottom(), style), minimumDistance); + } +} + +bool snapPointToNearest( + Point& point, + const std::vector& strokes, + float snapThreshold +) { + std::vector snapPoints; + for (const auto& stroke : strokes) { + collectSnapPointsForStroke(stroke, snapPoints); + } + + float bestDistance = snapThreshold; + const Point* bestPoint = nullptr; + for (const auto& snapPoint : snapPoints) { + const float distance = distanceBetween(point, snapPoint); + if (distance <= bestDistance) { + bestDistance = distance; + bestPoint = &snapPoint; + } + } + + if (!bestPoint) { + return false; + } + + point.x = bestPoint->x; + point.y = bestPoint->y; + return true; +} + +void snapRecognizedShapeCandidateToStrokes( + ShapeCandidate& candidate, + const std::vector& strokes, + float averageWidth +) { + if (!candidate.recognized || strokes.empty()) { + return; + } + + if (candidate.toolType != "shape-line" && candidate.toolType != "shape-polygon") { + return; + } + + const float snapThreshold = std::max(18.0f, averageWidth * 4.0f); + if (candidate.toolType == "shape-line") { + if (!candidate.points.empty()) { + snapPointToNearest(candidate.points.front(), strokes, snapThreshold); + snapPointToNearest(candidate.points.back(), strokes, snapThreshold); + } + return; + } + + for (auto& point : candidate.points) { + snapPointToNearest(point, strokes, snapThreshold); + } +} + +float lineFitScore(const std::vector& points, float endDistance, float strokeLength, float averageWidth) { + if (points.size() < 2 || endDistance < 0.001f) { + return 999.0f; + } + + const Point& start = points.front(); + const Point& end = points.back(); + const float dx = end.x - start.x; + const float dy = end.y - start.y; + float totalDeviation = 0.0f; + float maxDeviation = 0.0f; + + for (const auto& point : points) { + const float deviation = std::fabs(dy * point.x - dx * point.y + end.x * start.y - end.y * start.x) / endDistance; + totalDeviation += deviation; + maxDeviation = std::max(maxDeviation, deviation); + } + + const float meanDeviation = totalDeviation / static_cast(points.size()); + const float meanTolerance = std::max(10.0f, std::max(averageWidth * 3.0f, endDistance * 0.060f)); + const float maxTolerance = std::max(18.0f, std::max(averageWidth * 6.0f, endDistance * 0.16f)); + const float lengthRatio = strokeLength / endDistance; + + if (meanDeviation > meanTolerance || maxDeviation > maxTolerance || lengthRatio > 1.55f) { + return 999.0f; + } + + return (meanDeviation / std::max(1.0f, endDistance)) + std::max(0.0f, lengthRatio - 1.0f) * 0.35f; +} + +float ellipseCoverage(const std::vector& points, float centerX, float centerY) { + if (points.size() < 4) { + return 0.0f; + } + + std::vector angles; + angles.reserve(points.size()); + for (const auto& point : points) { + angles.push_back(std::atan2(point.y - centerY, point.x - centerX)); + } + + std::sort(angles.begin(), angles.end()); + + float largestGap = 0.0f; + for (size_t i = 1; i < angles.size(); ++i) { + largestGap = std::max(largestGap, angles[i] - angles[i - 1]); + } + largestGap = std::max(largestGap, (angles.front() + 2.0f * kPi) - angles.back()); + + return (2.0f * kPi) - largestGap; +} + +float ellipseFitScore(const std::vector& points, const SkRect& bounds, float closureDistance, float diagonal) { + const float radiusX = bounds.width() / 2.0f; + const float radiusY = bounds.height() / 2.0f; + if (radiusX < 8.0f || radiusY < 8.0f) { + return 999.0f; + } + + const float centerX = bounds.centerX(); + const float centerY = bounds.centerY(); + float totalError = 0.0f; + + for (const auto& point : points) { + const float nx = (point.x - centerX) / radiusX; + const float ny = (point.y - centerY) / radiusY; + totalError += std::fabs(std::sqrt(nx * nx + ny * ny) - 1.0f); + } + + const float meanError = totalError / static_cast(points.size()); + const float coverage = ellipseCoverage(points, centerX, centerY); + if (meanError > 0.30f || coverage < (kPi * 1.45f)) { + return 999.0f; + } + + return meanError + + (closureDistance / std::max(1.0f, diagonal)) * 0.35f + + (1.0f - coverage / (2.0f * kPi)) * 0.45f; +} + +float rectangleFitScore(const std::vector& points, const SkRect& bounds, float closureDistance, float diagonal) { + const float width = bounds.width(); + const float height = bounds.height(); + const float minDimension = std::min(width, height); + if (minDimension < 16.0f) { + return 999.0f; + } + + const float cornerTolerance = std::max(22.0f, diagonal * 0.30f); + const float sideTolerance = std::max(14.0f, minDimension * 0.16f); + const float corners[4][2] = { + { bounds.left(), bounds.top() }, + { bounds.right(), bounds.top() }, + { bounds.right(), bounds.bottom() }, + { bounds.left(), bounds.bottom() }, + }; + + bool hasCorner[4] = { false, false, false, false }; + int sideCounts[4] = { 0, 0, 0, 0 }; + float totalSideDistance = 0.0f; + + for (const auto& point : points) { + for (int i = 0; i < 4; ++i) { + if (distanceBetween(point.x, point.y, corners[i][0], corners[i][1]) <= cornerTolerance) { + hasCorner[i] = true; + } + } + + const float distances[4] = { + std::fabs(point.y - bounds.top()), + std::fabs(point.x - bounds.right()), + std::fabs(point.y - bounds.bottom()), + std::fabs(point.x - bounds.left()), + }; + + int nearestSide = 0; + float nearestDistance = distances[0]; + for (int i = 1; i < 4; ++i) { + if (distances[i] < nearestDistance) { + nearestDistance = distances[i]; + nearestSide = i; + } + } + + totalSideDistance += nearestDistance; + if (nearestDistance <= sideTolerance) { + sideCounts[nearestSide] += 1; + } + } + + for (int i = 0; i < 4; ++i) { + if (!hasCorner[i]) { + return 999.0f; + } + } + + const int minimumSideSamples = std::max(2, static_cast(points.size() * 0.035f)); + for (int sideCount : sideCounts) { + if (sideCount < minimumSideSamples) { + return 999.0f; + } + } + + const float meanSideDistance = totalSideDistance / static_cast(points.size()); + const float normalizedSideError = meanSideDistance / std::max(1.0f, minDimension); + if (normalizedSideError > 0.13f) { + return 999.0f; + } + + return normalizedSideError + (closureDistance / std::max(1.0f, diagonal)) * 0.30f; +} + +long endpointHoldDurationMillis( + const std::vector& points, + long endTimestamp, + float averageWidth +) { + if (points.empty() || endTimestamp <= 0) { + return 0; + } + + const Point& endpoint = points.back(); + if (endpoint.timestamp <= 0) { + return 0; + } + + const float holdRadius = std::max(8.0f, averageWidth * 1.5f); + const float holdRadiusSq = holdRadius * holdRadius; + long heldSince = endpoint.timestamp; + + for (auto it = points.rbegin(); it != points.rend(); ++it) { + if (it->timestamp <= 0) { + break; + } + + const float dx = it->x - endpoint.x; + const float dy = it->y - endpoint.y; + if (dx * dx + dy * dy > holdRadiusSq) { + break; + } + + heldSince = it->timestamp; + } + + return std::max(0, endTimestamp - heldSince); +} + +ShapeCandidate recognizeHeldShape( + const std::vector& points, + const std::string& currentTool, + long endTimestamp +) { + ShapeCandidate empty; + + if (!canRecognizeShapeForTool(currentTool) || points.size() < 2) { + return empty; + } + + const float averageWidth = averageCalculatedWidth(points); + if (endpointHoldDurationMillis(points, endTimestamp, averageWidth) < kHoldToShapeDurationMs) { + return empty; + } + + const SkRect bounds = boundsForPoints(points); + const float diagonal = std::sqrt(bounds.width() * bounds.width() + bounds.height() * bounds.height()); + if (diagonal < std::max(kMinimumShapeDiagonal, averageWidth * 3.0f)) { + return empty; + } + + const float strokeLength = pathLength(points); + const float closureDistance = distanceBetween(points.front(), points.back()); + const float endDistance = distanceBetween(points.front(), points.back()); + + if (endDistance >= std::max(18.0f, averageWidth * 2.0f)) { + const float score = lineFitScore(points, endDistance, strokeLength, averageWidth); + if (score < 0.30f) { + return makeLineCandidate(points); + } + } + + const bool isClosedEnough = closureDistance <= std::max(34.0f, diagonal * 0.30f); + if (!isClosedEnough || points.size() < 8) { + return empty; + } + + const float rectangleScore = rectangleFitScore(points, bounds, closureDistance, diagonal); + const float ellipseScore = ellipseFitScore(points, bounds, closureDistance, diagonal); + + if (rectangleScore < 999.0f && (ellipseScore >= 999.0f || rectangleScore <= ellipseScore * 0.86f)) { + return makeRectangleCandidate(points, bounds); + } + + const PolygonRoughFit roughPolygonFit = roughPolygonFitForStroke(points, bounds, averageWidth); + const float roundMeanThreshold = std::max(averageWidth * 0.85f, diagonal * 0.018f); + const float roundMaxThreshold = std::max(averageWidth * 2.0f, diagonal * 0.050f); + const bool highConfidenceEllipse = + ellipseScore < 0.16f && + roughPolygonFit.vertexCount >= 8; + const bool smoothLoopFitsEllipse = + highConfidenceEllipse || + ( + ellipseScore < 0.24f && + roughPolygonFit.vertexCount >= 6 && + ( + roughPolygonFit.meanError >= roundMeanThreshold || + roughPolygonFit.maxError >= roundMaxThreshold + ) + ); + + if (smoothLoopFitsEllipse) { + return makeEllipseCandidate(points, bounds); + } + + ShapeCandidate polygonCandidate = makePolygonCandidate(points, bounds, averageWidth); + if (polygonCandidate.recognized) { + return polygonCandidate; + } + + if (ellipseScore < 999.0f) { + return makeEllipseCandidate(points, bounds); + } + + return empty; +} + + +} // namespace nativedrawing diff --git a/cpp/ShapeRecognition.h b/cpp/ShapeRecognition.h new file mode 100644 index 0000000..8d72776 --- /dev/null +++ b/cpp/ShapeRecognition.h @@ -0,0 +1,59 @@ +#pragma once + +#include "DrawingTypes.h" +#include +#include +#include + +namespace nativedrawing { + +struct ShapeCandidate { + bool recognized = false; + std::string toolType; + std::vector points; +}; + +bool isRecognizedShapeToolType(const std::string& toolType); + +bool buildRecognizedShapePath( + const std::string& toolType, + const std::vector& points, + SkPath& path +); + +float distanceBetween(const Point& a, const Point& b); +float averageCalculatedWidth(const std::vector& points); +float averagePressure(const std::vector& points); + +Point centerPointForPoints(const std::vector& points); +std::vector transformedShapePoints( + const std::vector& basePoints, + const Point& anchor, + float referenceAngle, + float referenceDistance, + float targetX, + float targetY, + float scaleSensitivity, + float rotationSensitivity +); +std::vector transformedShapePointsCenterLockedToTarget( + const std::vector& basePoints, + const Point& center, + float referenceAngle, + float referenceDistance, + float targetX, + float targetY +); + +ShapeCandidate recognizeHeldShape( + const std::vector& points, + const std::string& currentTool, + long endTimestamp +); +void snapRecognizedShapeCandidateToStrokes( + ShapeCandidate& candidate, + const std::vector& strokes, + float averageWidth +); + +} // namespace nativedrawing diff --git a/cpp/SkiaDrawingEngine.cpp b/cpp/SkiaDrawingEngine.cpp index fa07c76..1963982 100644 --- a/cpp/SkiaDrawingEngine.cpp +++ b/cpp/SkiaDrawingEngine.cpp @@ -1,4 +1,6 @@ #include "SkiaDrawingEngine.h" +#include "ShapeRecognition.h" +#include "DrawingHistory.h" #include "DrawingSelection.h" #include "BackgroundRenderer.h" #include "DrawingSerialization.h" @@ -9,13 +11,8 @@ #include "ActiveStrokeRenderer.h" #include #include -#include -#include -#include -#include #include #include -#include namespace nativedrawing { @@ -32,9 +29,6 @@ constexpr float kPenAltitudeSmoothing = 0.24f; constexpr float kDefaultMinDistanceRealtime = 2.0f; constexpr float kPenMinDistanceRealtime = 1.0f; constexpr float kMinimumWidthDelta = 0.65f; -constexpr long kHoldToShapeDurationMs = 300; -constexpr float kMinimumShapeDiagonal = 30.0f; -constexpr float kPi = 3.14159265358979323846f; bool usesEnhancedPenProfile(const std::string& toolType, bool isPencilInput) { return toolType == "pen" && isPencilInput; @@ -61,2872 +55,784 @@ float limitWidthDelta(float previousWidth, float nextWidth, float baseWidth) { return nextWidth; } -SkColor swapRedBlueChannels(SkColor color) { - return SkColorSetARGB( - SkColorGetA(color), - SkColorGetB(color), - SkColorGetG(color), - SkColorGetR(color) - ); -} - -void normalizeStrokeColorsForRasterExport(std::vector& strokes) { - for (auto& stroke : strokes) { - if (stroke.isEraser) { - continue; - } - - stroke.paint.setColor(swapRedBlueChannels(stroke.paint.getColor())); - } -} - -bool canRecognizeShapeForTool(const std::string& toolType) { - return toolType == "pen" - || toolType == "pencil" - || toolType == "marker" - || toolType == "highlighter" - || toolType == "crayon" - || toolType == "calligraphy"; -} +} // namespace -float distanceBetween(float x1, float y1, float x2, float y2) { - const float dx = x2 - x1; - const float dy = y2 - y1; - return std::sqrt(dx * dx + dy * dy); -} +SkiaDrawingEngine::SkiaDrawingEngine(int width, int height) + : width_(width) + , height_(height) + , currentTool_("pen") + , eraserMode_("pixel") + , needsStrokeRedraw_(true) + , needsEraserMaskRedraw_(true) + , hasLastSmoothedPoint_(false) + , eraserCursorX_(0) + , eraserCursorY_(0) + , eraserCursorRadius_(0) + , showEraserCursor_(false) + , cachedEraserCircleCount_(0) + , bakedCircleCount_(0) + , maxAffectedStrokeIndex_(0) + , backgroundType_("plain") + , selection_(std::make_unique()) + , backgroundRenderer_(std::make_unique()) + , serializer_(std::make_unique()) + , pathRenderer_(std::make_unique()) + , eraserRenderer_(std::make_unique()) + , strokeSplitter_(std::make_unique(pathRenderer_.get())) + , batchExporter_(std::make_unique(width, height)) + , activeStrokeRenderer_(std::make_unique(width, height, pathRenderer_.get())) { -float distanceBetween(const Point& a, const Point& b) { - return distanceBetween(a.x, a.y, b.x, b.y); -} + // Initialize paint with high-quality settings + currentPaint_.setAntiAlias(true); + currentPaint_.setStyle(SkPaint::kStroke_Style); + currentPaint_.setStrokeWidth(3.0f); + currentPaint_.setColor(SK_ColorBLACK); + currentPaint_.setStrokeCap(SkPaint::kRound_Cap); + currentPaint_.setStrokeJoin(SkPaint::kRound_Join); + currentPaint_.setDither(false); -float averageCalculatedWidth(const std::vector& points) { - if (points.empty()) { - return 1.0f; - } + // Create offscreen surface for strokes only (transparent background) + SkImageInfo info = SkImageInfo::MakeN32Premul(width, height); + strokeSurface_ = SkSurfaces::Raster(info); - float total = 0.0f; - for (const auto& point : points) { - total += std::max(0.5f, point.calculatedWidth); + if (strokeSurface_) { + strokeSurface_->getCanvas()->clear(SK_ColorTRANSPARENT); + cachedStrokeSnapshot_ = strokeSurface_->makeImageSnapshot(); // Initial empty snapshot } - return total / static_cast(points.size()); -} -float averagePressure(const std::vector& points) { - if (points.empty()) { - return 1.0f; + // Create eraser mask surface (full RGBA for proper DstIn compositing) + eraserMaskSurface_ = SkSurfaces::Raster(info); + if (eraserMaskSurface_) { + eraserMaskSurface_->getCanvas()->clear(SK_ColorWHITE); } - float total = 0.0f; - for (const auto& point : points) { - total += std::max(0.1f, std::min(1.0f, point.pressure)); - } - return total / static_cast(points.size()); + // History starts empty -- canUndo/canRedo are derived from the + // stacks' emptiness so no sentinel is needed. } -float pathLength(const std::vector& points) { - if (points.size() < 2) { - return 0.0f; - } +SkiaDrawingEngine::~SkiaDrawingEngine() = default; - float total = 0.0f; - for (size_t i = 1; i < points.size(); ++i) { - total += distanceBetween(points[i - 1], points[i]); - } - return total; +void SkiaDrawingEngine::commitDelta(StrokeDelta&& delta) { + commitStrokeDelta(undoStack_, redoStack_, std::move(delta), MAX_HISTORY_ENTRIES); } -SkRect boundsForPoints(const std::vector& points) { - if (points.empty()) { - return SkRect::MakeEmpty(); - } - - float minX = points.front().x; - float maxX = points.front().x; - float minY = points.front().y; - float maxY = points.front().y; - - for (const auto& point : points) { - minX = std::min(minX, point.x); - maxX = std::max(maxX, point.x); - minY = std::min(minY, point.y); - maxY = std::max(maxY, point.y); - } - - return SkRect::MakeLTRB(minX, minY, maxX, maxY); +void SkiaDrawingEngine::recordPixelEraseCircleAdded(size_t strokeIndex, const EraserCircle& circle) { + appendPixelEraseCircleToDelta(pendingPixelEraseEntries_, strokeIndex, circle); } -struct ShapePointStyle { - float pressure = 1.0f; - float azimuthAngle = 0.0f; - float altitude = 1.57f; - float calculatedWidth = 1.0f; - long timestamp = 0; -}; - -ShapePointStyle styleForShapePoints(const std::vector& points) { - ShapePointStyle style; - if (points.empty()) { - return style; - } - - style.pressure = averagePressure(points); - style.calculatedWidth = averageCalculatedWidth(points); - style.azimuthAngle = points.back().azimuthAngle; - style.altitude = points.back().altitude; - style.timestamp = points.back().timestamp; - return style; +void SkiaDrawingEngine::applyDelta(const StrokeDelta& delta) { + applyStrokeDelta(delta, strokes_, eraserCircles_, *pathRenderer_); } -Point makeShapePoint(float x, float y, const ShapePointStyle& style) { - Point point; - point.x = x; - point.y = y; - point.pressure = style.pressure; - point.azimuthAngle = style.azimuthAngle; - point.altitude = style.altitude; - point.calculatedWidth = style.calculatedWidth; - point.timestamp = style.timestamp; - return point; +void SkiaDrawingEngine::revertDelta(const StrokeDelta& delta) { + revertStrokeDelta(delta, strokes_, eraserCircles_, *pathRenderer_); } -void appendLinePoints( - std::vector& output, - float x1, - float y1, - float x2, - float y2, - const ShapePointStyle& style, - float spacing, - bool includeStart +void SkiaDrawingEngine::touchBegan( + float x, + float y, + float pressure, + float azimuth, + float altitude, + long timestamp, + bool isPencilInput ) { - const float length = distanceBetween(x1, y1, x2, y2); - const int steps = std::max(1, static_cast(std::ceil(length / std::max(2.0f, spacing)))); - const int startStep = includeStart ? 0 : 1; - - for (int step = startStep; step <= steps; ++step) { - const float t = static_cast(step) / static_cast(steps); - output.push_back(makeShapePoint( - x1 + (x2 - x1) * t, - y1 + (y2 - y1) * t, - style - )); - } -} - -struct ShapeCandidate { - bool recognized = false; - std::string toolType; - std::vector points; -}; - -ShapeCandidate makeLineCandidate(const std::vector& points) { - ShapeCandidate candidate; - candidate.recognized = true; - candidate.toolType = "shape-line"; - const ShapePointStyle style = styleForShapePoints(points); - candidate.points.push_back(makeShapePoint(points.front().x, points.front().y, style)); - candidate.points.push_back(makeShapePoint(points.back().x, points.back().y, style)); - return candidate; -} + std::lock_guard lock(stateMutex_); -ShapeCandidate makeRectangleCandidate(const std::vector& points, const SkRect& bounds) { - ShapeCandidate candidate; - candidate.recognized = true; - candidate.toolType = "shape-rectangle"; + currentPoints_.clear(); + currentPath_.reset(); + predictedPointCount_ = 0; // Reset prediction tracking for new stroke + clearActiveShapePreview(); - const ShapePointStyle style = styleForShapePoints(points); - const float spacing = std::max(4.0f, style.calculatedWidth * 1.6f); + // Reset incremental active stroke state + activeStrokeRenderer_->reset(); - appendLinePoints(candidate.points, bounds.left(), bounds.top(), bounds.right(), bounds.top(), style, spacing, true); - appendLinePoints(candidate.points, bounds.right(), bounds.top(), bounds.right(), bounds.bottom(), style, spacing, false); - appendLinePoints(candidate.points, bounds.right(), bounds.bottom(), bounds.left(), bounds.bottom(), style, spacing, false); - appendLinePoints(candidate.points, bounds.left(), bounds.bottom(), bounds.left(), bounds.top(), style, spacing, false); + const bool enhancedPenProfile = usesEnhancedPenProfile(currentTool_, isPencilInput); + currentStrokeUsesEnhancedPenProfile_ = enhancedPenProfile; + float baseWidth = currentPaint_.getStrokeWidth(); + float calculatedWidth = pathRenderer_->calculateWidth( + pressure, + altitude, + baseWidth, + currentTool_, + enhancedPenProfile + ); - return candidate; -} + Point p = {x, y, pressure, azimuth, altitude, calculatedWidth, timestamp}; + currentPoints_.push_back(p); -ShapeCandidate makeEllipseCandidate(const std::vector& points, const SkRect& bounds) { - ShapeCandidate candidate; - candidate.recognized = true; + // Initialize smoothing with first point + lastSmoothedPoint_ = p; + hasLastSmoothedPoint_ = true; - const ShapePointStyle style = styleForShapePoints(points); - const float width = bounds.width(); - const float height = bounds.height(); - const float aspectError = std::fabs(width - height) / std::max(width, height); - const bool shouldForceCircle = aspectError <= 0.18f; - - float centerX = bounds.centerX(); - float centerY = bounds.centerY(); - float radiusX = width / 2.0f; - float radiusY = height / 2.0f; - - if (shouldForceCircle) { - const float radius = (radiusX + radiusY) / 2.0f; - radiusX = radius; - radiusY = radius; - candidate.toolType = "shape-circle"; - } else { - candidate.toolType = "shape-ellipse"; - } + currentPath_.moveTo(x, y); - const float circumferenceEstimate = 2.0f * kPi * std::sqrt((radiusX * radiusX + radiusY * radiusY) / 2.0f); - const int segments = std::max(40, std::min(128, static_cast(circumferenceEstimate / std::max(3.0f, style.calculatedWidth * 1.4f)))); - - candidate.points.reserve(static_cast(segments) + 1); - for (int i = 0; i <= segments; ++i) { - const float theta = (2.0f * kPi * static_cast(i)) / static_cast(segments); - candidate.points.push_back(makeShapePoint( - centerX + std::cos(theta) * radiusX, - centerY + std::sin(theta) * radiusY, - style - )); + // Start lasso selection if tool is select + if (currentTool_ == "select") { + selection_->lassoBegin(x, y); } - - return candidate; } -std::vector transformedShapePoints( - const std::vector& basePoints, - const Point& anchor, - float referenceAngle, - float referenceDistance, - float targetX, - float targetY, - float scaleSensitivity, - float rotationSensitivity +void SkiaDrawingEngine::touchMoved( + float x, + float y, + float pressure, + float azimuth, + float altitude, + long timestamp, + bool isPencilInput ) { - if (basePoints.empty()) { - return {}; - } + std::lock_guard lock(stateMutex_); - const float targetDx = targetX - anchor.x; - const float targetDy = targetY - anchor.y; - const float targetDistance = std::sqrt(targetDx * targetDx + targetDy * targetDy); - if (targetDistance < 0.001f || referenceDistance < 0.001f) { - return basePoints; - } + if (currentPoints_.empty()) return; - const float rawScale = targetDistance / referenceDistance; - const float scale = std::max( - 0.08f, - 1.0f + (rawScale - 1.0f) * std::max(0.0f, std::min(1.0f, scaleSensitivity)) - ); - const float rawRotation = std::atan2(targetDy, targetDx) - referenceAngle; - const float rotation = rawRotation * std::max(0.0f, std::min(1.0f, rotationSensitivity)); - const float cosRotation = std::cos(rotation); - const float sinRotation = std::sin(rotation); - - std::vector transformed; - transformed.reserve(basePoints.size()); - for (const auto& point : basePoints) { - const float localX = (point.x - anchor.x) * scale; - const float localY = (point.y - anchor.y) * scale; - Point next = point; - next.x = anchor.x + (localX * cosRotation - localY * sinRotation); - next.y = anchor.y + (localX * sinRotation + localY * cosRotation); - transformed.push_back(next); - } + if (hasActiveShapePreview_) { + const Point& endpoint = currentPoints_.back(); + const float dxFromEndpoint = x - endpoint.x; + const float dyFromEndpoint = y - endpoint.y; + const float preserveRadius = std::max(6.0f, currentPaint_.getStrokeWidth() * 1.25f); + if (dxFromEndpoint * dxFromEndpoint + dyFromEndpoint * dyFromEndpoint <= preserveRadius * preserveRadius) { + return; + } - return transformed; -} + if (updateActiveShapePreviewForPoint(x, y)) { + return; + } -Point centerPointForPoints(const std::vector& points) { - if (points.empty()) { - return makeShapePoint(0.0f, 0.0f, ShapePointStyle{}); + clearActiveShapePreview(); + activeStrokeRenderer_->reset(); } - float minX = points.front().x; - float maxX = points.front().x; - float minY = points.front().y; - float maxY = points.front().y; + const bool enhancedPenProfile = usesEnhancedPenProfile(currentTool_, isPencilInput); + const float coordinateSmoothingFactor = enhancedPenProfile + ? kPenCoordinateSmoothing + : kDefaultCoordinateSmoothing; + const float pressureSmoothingFactor = enhancedPenProfile + ? kPenPressureSmoothing + : kDefaultPressureSmoothing; + const float altitudeSmoothingFactor = enhancedPenProfile + ? kPenAltitudeSmoothing + : kDefaultAltitudeSmoothing; + + float smoothedX; + float smoothedY; + float smoothedPressure; + float smoothedAltitude; - for (const auto& point : points) { - minX = std::min(minX, point.x); - maxX = std::max(maxX, point.x); - minY = std::min(minY, point.y); - maxY = std::max(maxY, point.y); + if (hasLastSmoothedPoint_) { + smoothedX = interpolateValue(lastSmoothedPoint_.x, x, coordinateSmoothingFactor); + smoothedY = interpolateValue(lastSmoothedPoint_.y, y, coordinateSmoothingFactor); + smoothedPressure = interpolateValue(lastSmoothedPoint_.pressure, pressure, pressureSmoothingFactor); + smoothedAltitude = interpolateValue(lastSmoothedPoint_.altitude, altitude, altitudeSmoothingFactor); + } else { + smoothedX = x; + smoothedY = y; + smoothedPressure = pressure; + smoothedAltitude = altitude; } - Point center = points.front(); - center.x = (minX + maxX) * 0.5f; - center.y = (minY + maxY) * 0.5f; - return center; -} + // Point decimation - skip points too close together + const float minDistanceRealtime = pointDecimationDistance(currentTool_, isPencilInput); + if (!currentPoints_.empty()) { + const Point& last = currentPoints_.back(); + float dx = smoothedX - last.x; + float dy = smoothedY - last.y; + float distSq = dx * dx + dy * dy; -float clampedSignedScale(float rawScale) { - if (!std::isfinite(rawScale)) { - return 1.0f; - } + if (distSq < minDistanceRealtime * minDistanceRealtime) { + float baseWidth = currentPaint_.getStrokeWidth(); + float calculatedWidth = pathRenderer_->calculateWidth( + smoothedPressure, + smoothedAltitude, + baseWidth, + currentTool_, + enhancedPenProfile + ); - const float magnitude = std::max(0.08f, std::fabs(rawScale)); - return rawScale < 0.0f ? -magnitude : magnitude; -} + if (enhancedPenProfile && hasLastSmoothedPoint_) { + calculatedWidth = limitWidthDelta(lastSmoothedPoint_.calculatedWidth, calculatedWidth, baseWidth); + } -std::vector transformedShapePointsCenterLockedToTarget( - const std::vector& basePoints, - const Point& center, - float referenceAngle, - float referenceDistance, - float targetX, - float targetY -) { - if (basePoints.empty()) { - return {}; + Point p = { + smoothedX, + smoothedY, + smoothedPressure, + azimuth, + smoothedAltitude, + calculatedWidth, + timestamp + }; + lastSmoothedPoint_ = p; + hasLastSmoothedPoint_ = true; + return; + } } - const float refDx = std::cos(referenceAngle) * referenceDistance; - const float refDy = std::sin(referenceAngle) * referenceDistance; - const float targetDx = targetX - center.x; - const float targetDy = targetY - center.y; - const float targetDistance = std::sqrt(targetDx * targetDx + targetDy * targetDy); - const float fallbackScale = referenceDistance > 0.001f - ? targetDistance / referenceDistance - : 1.0f; - - const float scaleX = std::fabs(refDx) > 2.0f - ? clampedSignedScale(targetDx / refDx) - : clampedSignedScale(fallbackScale); - const float scaleY = std::fabs(refDy) > 2.0f - ? clampedSignedScale(targetDy / refDy) - : clampedSignedScale(fallbackScale); - - std::vector transformed; - transformed.reserve(basePoints.size()); - for (const auto& point : basePoints) { - Point next = point; - next.x = center.x + ((point.x - center.x) * scaleX); - next.y = center.y + ((point.y - center.y) * scaleY); - transformed.push_back(next); + float baseWidth = currentPaint_.getStrokeWidth(); + float calculatedWidth = pathRenderer_->calculateWidth( + smoothedPressure, + smoothedAltitude, + baseWidth, + currentTool_, + enhancedPenProfile + ); + + if (enhancedPenProfile && hasLastSmoothedPoint_) { + calculatedWidth = limitWidthDelta(lastSmoothedPoint_.calculatedWidth, calculatedWidth, baseWidth); } - return transformed; -} + Point p = { + smoothedX, + smoothedY, + smoothedPressure, + azimuth, + smoothedAltitude, + calculatedWidth, + timestamp + }; + currentPoints_.push_back(p); -float distanceFromPointToSegment(const Point& point, const Point& start, const Point& end) { - const float dx = end.x - start.x; - const float dy = end.y - start.y; - const float lengthSq = dx * dx + dy * dy; + lastSmoothedPoint_ = p; + hasLastSmoothedPoint_ = true; - if (lengthSq < 0.001f) { - return distanceBetween(point, start); - } + // Object Eraser Logic: Check for intersections during move + if (currentTool_ == "eraser" && eraserMode_ == "object") { + SkRect eraserRect = SkRect::MakeXYWH(x - 10, y - 10, 20, 20); - const float t = std::max(0.0f, std::min( - 1.0f, - ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSq - )); - - return distanceBetween( - point.x, - point.y, - start.x + (dx * t), - start.y + (dy * t) - ); -} + for (size_t i = 0; i < strokes_.size(); ++i) { + if (pendingDeleteIndices_.count(i) > 0) continue; + if (strokes_[i].isEraser) continue; -SkRect boundsForStrokeCopies(const std::unordered_map& strokes) { - bool hasPoint = false; - float minX = std::numeric_limits::max(); - float minY = std::numeric_limits::max(); - float maxX = std::numeric_limits::lowest(); - float maxY = std::numeric_limits::lowest(); - - for (const auto& entry : strokes) { - for (const auto& point : entry.second.points) { - hasPoint = true; - minX = std::min(minX, point.x); - minY = std::min(minY, point.y); - maxX = std::max(maxX, point.x); - maxY = std::max(maxY, point.y); + if (strokes_[i].path.getBounds().intersects(eraserRect)) { + pendingDeleteIndices_.insert(i); + } } + // OPTIMIZATION: Don't set needsStrokeRedraw_ - pending delete preview rendered directly } - if (!hasPoint) { - return SkRect::MakeEmpty(); - } + // Pixel Eraser Logic: PencilKit-style immediate stroke splitting + if (currentTool_ == "eraser" && eraserMode_ == "pixel") { + float eraserRadius = currentPaint_.getStrokeWidth() / 2.0f; - if (std::fabs(maxX - minX) < 1.0f) { - minX -= 0.5f; - maxX += 0.5f; - } - if (std::fabs(maxY - minY) < 1.0f) { - minY -= 0.5f; - maxY += 0.5f; + // Apply eraser and split strokes immediately - no eraserCircles_ needed + applyPixelEraserAt(p.x, p.y, eraserRadius); } - return SkRect::MakeLTRB(minX, minY, maxX, maxY); -} - -void getSelectionHandlePoints( - const SkRect& bounds, - int handleIndex, - float& anchorX, - float& anchorY, - float& handleX, - float& handleY, - bool& affectsX, - bool& affectsY -) { - const float centerX = (bounds.fLeft + bounds.fRight) * 0.5f; - const float centerY = (bounds.fTop + bounds.fBottom) * 0.5f; - - switch (handleIndex) { - case 0: - anchorX = bounds.fRight; anchorY = bounds.fBottom; - handleX = bounds.fLeft; handleY = bounds.fTop; - affectsX = true; affectsY = true; - break; - case 1: - anchorX = centerX; anchorY = bounds.fBottom; - handleX = centerX; handleY = bounds.fTop; - affectsX = false; affectsY = true; - break; - case 2: - anchorX = bounds.fLeft; anchorY = bounds.fBottom; - handleX = bounds.fRight; handleY = bounds.fTop; - affectsX = true; affectsY = true; - break; - case 3: - anchorX = bounds.fRight; anchorY = centerY; - handleX = bounds.fLeft; handleY = centerY; - affectsX = true; affectsY = false; - break; - case 4: - anchorX = bounds.fLeft; anchorY = centerY; - handleX = bounds.fRight; handleY = centerY; - affectsX = true; affectsY = false; - break; - case 5: - anchorX = bounds.fRight; anchorY = bounds.fTop; - handleX = bounds.fLeft; handleY = bounds.fBottom; - affectsX = true; affectsY = true; - break; - case 6: - anchorX = centerX; anchorY = bounds.fTop; - handleX = centerX; handleY = bounds.fBottom; - affectsX = false; affectsY = true; - break; - case 7: - default: - anchorX = bounds.fLeft; anchorY = bounds.fTop; - handleX = bounds.fRight; handleY = bounds.fBottom; - affectsX = true; affectsY = true; - break; + // Selection Tool Logic: Build lasso path during drag + if (currentTool_ == "select") { + selection_->lassoMove(x, y); + // Actual selection happens on touchEnded, not during drag } } -void simplifyPolylineRecursive( - const std::vector& points, - size_t first, - size_t last, - float epsilon, - std::vector& outputIndices -) { - if (last <= first + 1) { - return; - } +void SkiaDrawingEngine::touchEnded(long timestamp) { + std::lock_guard lock(stateMutex_); - float maxDistance = 0.0f; - size_t maxIndex = first; - for (size_t i = first + 1; i < last; ++i) { - const float distance = distanceFromPointToSegment(points[i], points[first], points[last]); - if (distance > maxDistance) { - maxDistance = distance; - maxIndex = i; - } - } + if (currentPoints_.empty()) return; - if (maxDistance <= epsilon || maxIndex == first) { - return; - } + if (currentTool_ == "eraser" && eraserMode_ == "object") { + eraseObjects(); + } else if (currentTool_ == "eraser" && eraserMode_ == "pixel") { + // Commit a single PixelErase delta covering every (stroke, circle) + // pair that was added during this drag. pendingPixelEraseEntries_ + // was populated incrementally by recordPixelEraseCircleAdded + // during applyPixelEraserAt calls. Reset for the next drag. + if (!pendingPixelEraseEntries_.empty()) { + StrokeDelta delta; + delta.kind = StrokeDelta::Kind::PixelErase; + delta.pixelEraseEntries = std::move(pendingPixelEraseEntries_); + commitDelta(std::move(delta)); + } + pendingPixelEraseEntries_.clear(); - simplifyPolylineRecursive(points, first, maxIndex, epsilon, outputIndices); - outputIndices.push_back(maxIndex); - simplifyPolylineRecursive(points, maxIndex, last, epsilon, outputIndices); -} + // DON'T set needsStrokeRedraw_ - kClear visual is already correct + // Full redraw only needed on undo/redo/deserialize -std::vector decimatedShapePoints(const std::vector& points, float minimumSpacing) { - std::vector decimated; - decimated.reserve(points.size()); + // Reset eraser position tracking for next stroke + hasLastEraserPoint_ = false; - for (const auto& point : points) { - if (decimated.empty() || distanceBetween(decimated.back(), point) >= minimumSpacing) { - decimated.push_back(point); + currentPoints_.clear(); + currentPath_.reset(); + } else if (currentTool_ == "select") { + // Finalize lasso selection - select strokes inside/intersecting the lasso + selection_->lassoEnd(strokes_, selectedIndices_); + // Pre-cache for smooth drag start + if (!selectedIndices_.empty()) { + prepareSelectionDragCache(); } + currentPoints_.clear(); + currentPath_.reset(); + } else { + finishStroke(timestamp); } - if (decimated.size() > 3 && distanceBetween(decimated.front(), decimated.back()) < minimumSpacing) { - decimated.pop_back(); - } - - return decimated; + hasLastSmoothedPoint_ = false; + predictedPointCount_ = 0; // Reset prediction tracking + currentStrokeUsesEnhancedPenProfile_ = false; } -std::vector simplifyPolyline(const std::vector& points, float epsilon) { - if (points.size() <= 2) { - return points; +bool SkiaDrawingEngine::updateHoldShapePreview(long timestamp) { + std::lock_guard lock(stateMutex_); + + if (currentPoints_.empty() || currentTool_ == "select" || currentTool_ == "eraser") { + clearActiveShapePreview(); + return false; } - std::vector indices; - indices.reserve(points.size()); - indices.push_back(0); - simplifyPolylineRecursive(points, 0, points.size() - 1, epsilon, indices); - indices.push_back(points.size() - 1); - std::sort(indices.begin(), indices.end()); - indices.erase(std::unique(indices.begin(), indices.end()), indices.end()); - - std::vector simplified; - simplified.reserve(indices.size()); - for (size_t index : indices) { - simplified.push_back(points[index]); + ShapeCandidate candidate = recognizeHeldShape(currentPoints_, currentTool_, timestamp); + if (!candidate.recognized) { + return false; } - return simplified; -} + snapRecognizedShapeCandidateToStrokes(candidate, strokes_, averageCalculatedWidth(currentPoints_)); -std::vector removeNearlyCollinearVertices( - const std::vector& vertices, - float tolerance -) { - if (vertices.size() <= 3) { - return vertices; - } + activeShapePreviewToolType_ = std::move(candidate.toolType); + activeShapePreviewPoints_ = std::move(candidate.points); + activeShapePreviewBasePoints_ = activeShapePreviewPoints_; + buildRecognizedShapePath(activeShapePreviewToolType_, activeShapePreviewPoints_, activeShapePreviewPath_); + hasActiveShapePreview_ = true; + activeShapePreviewAnchorPoint_ = activeShapePreviewToolType_ == "shape-line" + ? activeShapePreviewPoints_.front() + : centerPointForPoints(activeShapePreviewPoints_); + hasActiveShapePreviewAnchor_ = !activeShapePreviewPoints_.empty(); + activeShapePreviewReferenceAngle_ = std::atan2( + currentPoints_.back().y - activeShapePreviewAnchorPoint_.y, + currentPoints_.back().x - activeShapePreviewAnchorPoint_.x + ); + activeShapePreviewReferenceDistance_ = std::max( + 1.0f, + distanceBetween(activeShapePreviewAnchorPoint_, currentPoints_.back()) + ); - std::vector pruned = vertices; - bool removed = true; - while (removed && pruned.size() > 3) { - removed = false; - for (size_t i = 0; i < pruned.size(); ++i) { - const Point& previous = pruned[(i + pruned.size() - 1) % pruned.size()]; - const Point& current = pruned[i]; - const Point& next = pruned[(i + 1) % pruned.size()]; - - if (distanceFromPointToSegment(current, previous, next) <= tolerance) { - pruned.erase(pruned.begin() + static_cast(i)); - removed = true; - break; - } - } - } + // Once the shape is previewing, the cached freehand active surface should + // stop contributing pixels; the source stroke points remain intact for + // final recognition or for continuing freehand if the user moves again. + activeStrokeRenderer_->reset(); - return pruned; + return true; } -float polygonFitMeanError( - const std::vector& sourcePoints, - const std::vector& vertices, - float* maxErrorOut -) { - if (vertices.size() < 3 || sourcePoints.empty()) { - if (maxErrorOut) { - *maxErrorOut = std::numeric_limits::max(); - } - return std::numeric_limits::max(); +bool SkiaDrawingEngine::updateActiveShapePreviewForPoint(float x, float y) { + if (!hasActiveShapePreview_ + || !hasActiveShapePreviewAnchor_ + || activeShapePreviewBasePoints_.empty()) { + return false; } - float totalError = 0.0f; - float maxError = 0.0f; - for (const auto& sourcePoint : sourcePoints) { - float nearestDistance = std::numeric_limits::max(); - for (size_t i = 0; i < vertices.size(); ++i) { - const Point& start = vertices[i]; - const Point& end = vertices[(i + 1) % vertices.size()]; - nearestDistance = std::min( - nearestDistance, - distanceFromPointToSegment(sourcePoint, start, end) - ); - } - totalError += nearestDistance; - maxError = std::max(maxError, nearestDistance); - } + std::vector transformed = + (activeShapePreviewToolType_ == "shape-line" || activeShapePreviewToolType_ == "shape-circle") + ? transformedShapePoints( + activeShapePreviewBasePoints_, + activeShapePreviewAnchorPoint_, + activeShapePreviewReferenceAngle_, + activeShapePreviewReferenceDistance_, + x, + y, + 1.0f, + activeShapePreviewToolType_ == "shape-line" ? 1.0f : 0.0f + ) + : transformedShapePointsCenterLockedToTarget( + activeShapePreviewBasePoints_, + activeShapePreviewAnchorPoint_, + activeShapePreviewReferenceAngle_, + activeShapePreviewReferenceDistance_, + x, + y + ); - if (maxErrorOut) { - *maxErrorOut = maxError; + if (transformed.size() < 2) { + return false; } - return totalError / static_cast(sourcePoints.size()); -} -struct PolygonRoughFit { - size_t vertexCount = 0; - float meanError = std::numeric_limits::max(); - float maxError = std::numeric_limits::max(); -}; - -PolygonRoughFit roughPolygonFitForStroke( - const std::vector& points, - const SkRect& bounds, - float averageWidth -) { - PolygonRoughFit fit; + ShapeCandidate candidate; + candidate.recognized = true; + candidate.toolType = activeShapePreviewToolType_; + candidate.points = std::move(transformed); + snapRecognizedShapeCandidateToStrokes(candidate, strokes_, averageCalculatedWidth(candidate.points)); - const float diagonal = std::sqrt(bounds.width() * bounds.width() + bounds.height() * bounds.height()); - const float decimationSpacing = std::max(3.0f, averageWidth * 1.2f); - const std::vector sourcePoints = decimatedShapePoints(points, decimationSpacing); - if (sourcePoints.size() < 6) { - return fit; + SkPath path; + if (!buildRecognizedShapePath(candidate.toolType, candidate.points, path)) { + return false; } - const float epsilon = std::max(8.0f, std::max(averageWidth * 2.2f, diagonal * 0.028f)); - std::vector vertices = simplifyPolyline(sourcePoints, epsilon); - if (vertices.size() > 3 && distanceBetween(vertices.front(), vertices.back()) <= std::max(12.0f, averageWidth * 3.0f)) { - vertices.pop_back(); - } - vertices = removeNearlyCollinearVertices(vertices, std::max(5.0f, averageWidth * 1.7f)); + activeShapePreviewPoints_ = std::move(candidate.points); + activeShapePreviewPath_ = path; + activeStrokeRenderer_->reset(); + return true; +} - fit.vertexCount = vertices.size(); - if (vertices.size() >= 3) { - fit.meanError = polygonFitMeanError(sourcePoints, vertices, &fit.maxError); - } +// PREDICTIVE TOUCH: Clear predicted points before adding new actual data +// Called at the start of each touchMoved to discard old predictions +void SkiaDrawingEngine::clearPredictedPoints() { + std::lock_guard lock(stateMutex_); - return fit; + if (predictedPointCount_ > 0 && currentPoints_.size() >= predictedPointCount_) { + currentPoints_.erase( + currentPoints_.end() - predictedPointCount_, + currentPoints_.end() + ); + predictedPointCount_ = 0; + } } -std::vector reducePolygonVerticesByFit( - const std::vector& sourcePoints, - const std::vector& vertices, - float averageWidth, - float diagonal +// PREDICTIVE TOUCH: Add a predicted point from Apple Pencil +// These points are rendered but will be replaced by actual data on next touch event +void SkiaDrawingEngine::addPredictedPoint( + float x, + float y, + float pressure, + float azimuth, + float altitude, + long timestamp, + bool isPencilInput ) { - if (vertices.size() <= 3) { - return vertices; - } + std::lock_guard lock(stateMutex_); - const float meanTolerance = std::max(10.0f, std::max(averageWidth * 2.8f, diagonal * 0.045f)); - const float maxTolerance = std::max(24.0f, std::max(averageWidth * 5.0f, diagonal * 0.14f)); - std::vector reduced = vertices; - - while (reduced.size() > 3) { - size_t bestRemoveIndex = std::numeric_limits::max(); - float bestScore = std::numeric_limits::max(); - std::vector bestVertices; - - for (size_t i = 0; i < reduced.size(); ++i) { - std::vector trial; - trial.reserve(reduced.size() - 1); - for (size_t j = 0; j < reduced.size(); ++j) { - if (j != i) { - trial.push_back(reduced[j]); - } - } + if (currentPoints_.empty()) return; - float maxError = 0.0f; - const float meanError = polygonFitMeanError(sourcePoints, trial, &maxError); - if (meanError > meanTolerance || maxError > maxTolerance) { - continue; - } + const bool enhancedPenProfile = usesEnhancedPenProfile(currentTool_, isPencilInput); + const float coordinateSmoothingFactor = enhancedPenProfile + ? kPenCoordinateSmoothing + : kDefaultCoordinateSmoothing; + const float pressureSmoothingFactor = enhancedPenProfile + ? kPenPressureSmoothing + : kDefaultPressureSmoothing; + const float altitudeSmoothingFactor = enhancedPenProfile + ? kPenAltitudeSmoothing + : kDefaultAltitudeSmoothing; + const Point& lastPoint = currentPoints_.back(); - const float score = meanError + maxError * 0.08f; - if (score < bestScore) { - bestScore = score; - bestRemoveIndex = i; - bestVertices = std::move(trial); - } - } + float smoothedX = interpolateValue(lastPoint.x, x, coordinateSmoothingFactor); + float smoothedY = interpolateValue(lastPoint.y, y, coordinateSmoothingFactor); + float smoothedPressure = interpolateValue(lastPoint.pressure, pressure, pressureSmoothingFactor); + float smoothedAltitude = interpolateValue(lastPoint.altitude, altitude, altitudeSmoothingFactor); - if (bestRemoveIndex == std::numeric_limits::max()) { - break; - } + float baseWidth = currentPaint_.getStrokeWidth(); + float calculatedWidth = pathRenderer_->calculateWidth( + smoothedPressure, + smoothedAltitude, + baseWidth, + currentTool_, + enhancedPenProfile + ); - reduced = std::move(bestVertices); + if (enhancedPenProfile) { + calculatedWidth = limitWidthDelta(lastPoint.calculatedWidth, calculatedWidth, baseWidth); } - return reduced; + Point p = { + smoothedX, + smoothedY, + smoothedPressure, + azimuth, + smoothedAltitude, + calculatedWidth, + timestamp + }; + currentPoints_.push_back(p); + predictedPointCount_++; } -ShapeCandidate makePolygonCandidate( - const std::vector& points, - const SkRect& bounds, - float averageWidth -) { - ShapeCandidate candidate; - - const float diagonal = std::sqrt(bounds.width() * bounds.width() + bounds.height() * bounds.height()); - const float decimationSpacing = std::max(3.0f, averageWidth * 1.2f); - const std::vector sourcePoints = decimatedShapePoints(points, decimationSpacing); - if (sourcePoints.size() < 6) { - return candidate; +void SkiaDrawingEngine::finishStroke(long endTimestamp) { + if (currentPoints_.size() < 2) { + currentPoints_.clear(); + currentPath_.reset(); + clearActiveShapePreview(); + currentStrokeUsesEnhancedPenProfile_ = false; + return; } - const float epsilon = std::max(8.0f, std::max(averageWidth * 2.2f, diagonal * 0.028f)); - std::vector vertices = simplifyPolyline(sourcePoints, epsilon); - if (vertices.size() > 3 && distanceBetween(vertices.front(), vertices.back()) <= std::max(12.0f, averageWidth * 3.0f)) { - vertices.pop_back(); + ShapeCandidate shapeCandidate; + if (hasActiveShapePreview_) { + shapeCandidate.recognized = true; + shapeCandidate.toolType = activeShapePreviewToolType_; + shapeCandidate.points = activeShapePreviewPoints_; + } else { + shapeCandidate = recognizeHeldShape(currentPoints_, currentTool_, endTimestamp); } - vertices = removeNearlyCollinearVertices(vertices, std::max(5.0f, averageWidth * 1.7f)); - vertices = reducePolygonVerticesByFit(sourcePoints, vertices, averageWidth, diagonal); - vertices = removeNearlyCollinearVertices(vertices, std::max(5.0f, averageWidth * 1.7f)); - if (vertices.size() < 3 || vertices.size() > 12) { - return candidate; - } + snapRecognizedShapeCandidateToStrokes(shapeCandidate, strokes_, averageCalculatedWidth(currentPoints_)); - float maxError = 0.0f; - const float meanError = polygonFitMeanError(sourcePoints, vertices, &maxError); - const float meanTolerance = std::max(10.0f, std::max(averageWidth * 2.8f, diagonal * 0.045f)); - const float maxTolerance = std::max(24.0f, std::max(averageWidth * 5.0f, diagonal * 0.14f)); - if (meanError > meanTolerance || maxError > maxTolerance) { - return candidate; - } + Stroke stroke; + stroke.points = shapeCandidate.recognized ? shapeCandidate.points : currentPoints_; + stroke.paint = currentPaint_; + stroke.isEraser = (currentTool_ == "eraser" && eraserMode_ == "pixel"); + stroke.toolType = shapeCandidate.recognized + ? shapeCandidate.toolType + : currentTool_; // Store tool type for specialized rendering - const ShapePointStyle style = styleForShapePoints(points); - candidate.recognized = true; - candidate.toolType = "shape-polygon"; - candidate.points.reserve(vertices.size()); - for (const auto& vertex : vertices) { - candidate.points.push_back(makeShapePoint(vertex.x, vertex.y, style)); + // Keep Apple Pencil pen strokes fully opaque so anti-aliased edges stay + // crisp after the stroke is finalized. Other tools keep their existing + // opacity behavior. + if (!stroke.points.empty()) { + if (currentStrokeUsesEnhancedPenProfile_ + || currentTool_ == "highlighter" + || currentTool_ == "marker" + || currentTool_ == "crayon") { + stroke.originalAlphaMod = 1.0f; + } else { + float avgPressure = 0.0f; + for (const auto& pt : stroke.points) { + avgPressure += pt.pressure; + } + avgPressure /= stroke.points.size(); + stroke.originalAlphaMod = 0.85f + (avgPressure * 0.15f); // Subtler range: 85%-100% + } } - return candidate; -} -bool appendUniqueSnapPoint(std::vector& output, const Point& point, float minimumDistance) { - for (const auto& existing : output) { - if (distanceBetween(existing, point) <= minimumDistance) { - return false; - } + if (!buildRecognizedShapePath(stroke.toolType, stroke.points, stroke.path)) { + smoothPath(stroke.points, stroke.path); } - output.push_back(point); - return true; -} - -void collectSnapPointsForStroke(const Stroke& stroke, std::vector& output) { - if (stroke.points.empty() || stroke.isEraser) { - return; - } - - const float minimumDistance = std::max(8.0f, averageCalculatedWidth(stroke.points) * 1.5f); - - if (stroke.toolType == "shape-line") { - appendUniqueSnapPoint(output, stroke.points.front(), minimumDistance); - appendUniqueSnapPoint(output, stroke.points.back(), minimumDistance); - return; - } - - if (stroke.toolType == "shape-polygon") { - for (const auto& point : stroke.points) { - appendUniqueSnapPoint(output, point, minimumDistance); - } - return; - } - - if (stroke.toolType == "shape-rectangle") { - const SkRect bounds = boundsForPoints(stroke.points); - const ShapePointStyle style = styleForShapePoints(stroke.points); - appendUniqueSnapPoint(output, makeShapePoint(bounds.left(), bounds.top(), style), minimumDistance); - appendUniqueSnapPoint(output, makeShapePoint(bounds.right(), bounds.top(), style), minimumDistance); - appendUniqueSnapPoint(output, makeShapePoint(bounds.right(), bounds.bottom(), style), minimumDistance); - appendUniqueSnapPoint(output, makeShapePoint(bounds.left(), bounds.bottom(), style), minimumDistance); - } -} - -bool snapPointToNearest( - Point& point, - const std::vector& strokes, - float snapThreshold -) { - std::vector snapPoints; - for (const auto& stroke : strokes) { - collectSnapPointsForStroke(stroke, snapPoints); - } - - float bestDistance = snapThreshold; - const Point* bestPoint = nullptr; - for (const auto& snapPoint : snapPoints) { - const float distance = distanceBetween(point, snapPoint); - if (distance <= bestDistance) { - bestDistance = distance; - bestPoint = &snapPoint; - } - } - - if (!bestPoint) { - return false; - } - - point.x = bestPoint->x; - point.y = bestPoint->y; - return true; -} - -void snapRecognizedShapeCandidateToStrokes( - ShapeCandidate& candidate, - const std::vector& strokes, - float averageWidth -) { - if (!candidate.recognized || strokes.empty()) { - return; - } - - if (candidate.toolType != "shape-line" && candidate.toolType != "shape-polygon") { - return; - } - - const float snapThreshold = std::max(18.0f, averageWidth * 4.0f); - if (candidate.toolType == "shape-line") { - if (!candidate.points.empty()) { - snapPointToNearest(candidate.points.front(), strokes, snapThreshold); - snapPointToNearest(candidate.points.back(), strokes, snapThreshold); - } - return; - } - - for (auto& point : candidate.points) { - snapPointToNearest(point, strokes, snapThreshold); - } -} - -float lineFitScore(const std::vector& points, float endDistance, float strokeLength, float averageWidth) { - if (points.size() < 2 || endDistance < 0.001f) { - return 999.0f; - } - - const Point& start = points.front(); - const Point& end = points.back(); - const float dx = end.x - start.x; - const float dy = end.y - start.y; - float totalDeviation = 0.0f; - float maxDeviation = 0.0f; - - for (const auto& point : points) { - const float deviation = std::fabs(dy * point.x - dx * point.y + end.x * start.y - end.y * start.x) / endDistance; - totalDeviation += deviation; - maxDeviation = std::max(maxDeviation, deviation); - } - - const float meanDeviation = totalDeviation / static_cast(points.size()); - const float meanTolerance = std::max(10.0f, std::max(averageWidth * 3.0f, endDistance * 0.060f)); - const float maxTolerance = std::max(18.0f, std::max(averageWidth * 6.0f, endDistance * 0.16f)); - const float lengthRatio = strokeLength / endDistance; - - if (meanDeviation > meanTolerance || maxDeviation > maxTolerance || lengthRatio > 1.55f) { - return 999.0f; - } - - return (meanDeviation / std::max(1.0f, endDistance)) + std::max(0.0f, lengthRatio - 1.0f) * 0.35f; -} - -float ellipseCoverage(const std::vector& points, float centerX, float centerY) { - if (points.size() < 4) { - return 0.0f; - } - - std::vector angles; - angles.reserve(points.size()); - for (const auto& point : points) { - angles.push_back(std::atan2(point.y - centerY, point.x - centerX)); - } - - std::sort(angles.begin(), angles.end()); - - float largestGap = 0.0f; - for (size_t i = 1; i < angles.size(); ++i) { - largestGap = std::max(largestGap, angles[i] - angles[i - 1]); - } - largestGap = std::max(largestGap, (angles.front() + 2.0f * kPi) - angles.back()); - - return (2.0f * kPi) - largestGap; -} - -float ellipseFitScore(const std::vector& points, const SkRect& bounds, float closureDistance, float diagonal) { - const float radiusX = bounds.width() / 2.0f; - const float radiusY = bounds.height() / 2.0f; - if (radiusX < 8.0f || radiusY < 8.0f) { - return 999.0f; - } - - const float centerX = bounds.centerX(); - const float centerY = bounds.centerY(); - float totalError = 0.0f; - - for (const auto& point : points) { - const float nx = (point.x - centerX) / radiusX; - const float ny = (point.y - centerY) / radiusY; - totalError += std::fabs(std::sqrt(nx * nx + ny * ny) - 1.0f); - } - - const float meanError = totalError / static_cast(points.size()); - const float coverage = ellipseCoverage(points, centerX, centerY); - if (meanError > 0.30f || coverage < (kPi * 1.45f)) { - return 999.0f; - } - - return meanError - + (closureDistance / std::max(1.0f, diagonal)) * 0.35f - + (1.0f - coverage / (2.0f * kPi)) * 0.45f; -} - -float rectangleFitScore(const std::vector& points, const SkRect& bounds, float closureDistance, float diagonal) { - const float width = bounds.width(); - const float height = bounds.height(); - const float minDimension = std::min(width, height); - if (minDimension < 16.0f) { - return 999.0f; - } - - const float cornerTolerance = std::max(22.0f, diagonal * 0.30f); - const float sideTolerance = std::max(14.0f, minDimension * 0.16f); - const float corners[4][2] = { - { bounds.left(), bounds.top() }, - { bounds.right(), bounds.top() }, - { bounds.right(), bounds.bottom() }, - { bounds.left(), bounds.bottom() }, - }; - - bool hasCorner[4] = { false, false, false, false }; - int sideCounts[4] = { 0, 0, 0, 0 }; - float totalSideDistance = 0.0f; - - for (const auto& point : points) { - for (int i = 0; i < 4; ++i) { - if (distanceBetween(point.x, point.y, corners[i][0], corners[i][1]) <= cornerTolerance) { - hasCorner[i] = true; - } - } - - const float distances[4] = { - std::fabs(point.y - bounds.top()), - std::fabs(point.x - bounds.right()), - std::fabs(point.y - bounds.bottom()), - std::fabs(point.x - bounds.left()), - }; - - int nearestSide = 0; - float nearestDistance = distances[0]; - for (int i = 1; i < 4; ++i) { - if (distances[i] < nearestDistance) { - nearestDistance = distances[i]; - nearestSide = i; - } - } - - totalSideDistance += nearestDistance; - if (nearestDistance <= sideTolerance) { - sideCounts[nearestSide] += 1; - } - } - - for (int i = 0; i < 4; ++i) { - if (!hasCorner[i]) { - return 999.0f; - } - } - - const int minimumSideSamples = std::max(2, static_cast(points.size() * 0.035f)); - for (int sideCount : sideCounts) { - if (sideCount < minimumSideSamples) { - return 999.0f; - } - } - - const float meanSideDistance = totalSideDistance / static_cast(points.size()); - const float normalizedSideError = meanSideDistance / std::max(1.0f, minDimension); - if (normalizedSideError > 0.13f) { - return 999.0f; - } - - return normalizedSideError + (closureDistance / std::max(1.0f, diagonal)) * 0.30f; -} - -long endpointHoldDurationMillis( - const std::vector& points, - long endTimestamp, - float averageWidth -) { - if (points.empty() || endTimestamp <= 0) { - return 0; - } - - const Point& endpoint = points.back(); - if (endpoint.timestamp <= 0) { - return 0; - } - - const float holdRadius = std::max(8.0f, averageWidth * 1.5f); - const float holdRadiusSq = holdRadius * holdRadius; - long heldSince = endpoint.timestamp; - - for (auto it = points.rbegin(); it != points.rend(); ++it) { - if (it->timestamp <= 0) { - break; - } - - const float dx = it->x - endpoint.x; - const float dy = it->y - endpoint.y; - if (dx * dx + dy * dy > holdRadiusSq) { - break; - } - - heldSince = it->timestamp; - } - - return std::max(0, endTimestamp - heldSince); -} - -ShapeCandidate recognizeHeldShape( - const std::vector& points, - const std::string& currentTool, - long endTimestamp -) { - ShapeCandidate empty; - - if (!canRecognizeShapeForTool(currentTool) || points.size() < 2) { - return empty; - } - - const float averageWidth = averageCalculatedWidth(points); - if (endpointHoldDurationMillis(points, endTimestamp, averageWidth) < kHoldToShapeDurationMs) { - return empty; - } - - const SkRect bounds = boundsForPoints(points); - const float diagonal = std::sqrt(bounds.width() * bounds.width() + bounds.height() * bounds.height()); - if (diagonal < std::max(kMinimumShapeDiagonal, averageWidth * 3.0f)) { - return empty; - } - - const float strokeLength = pathLength(points); - const float closureDistance = distanceBetween(points.front(), points.back()); - const float endDistance = distanceBetween(points.front(), points.back()); - - if (endDistance >= std::max(18.0f, averageWidth * 2.0f)) { - const float score = lineFitScore(points, endDistance, strokeLength, averageWidth); - if (score < 0.30f) { - return makeLineCandidate(points); - } - } - - const bool isClosedEnough = closureDistance <= std::max(34.0f, diagonal * 0.30f); - if (!isClosedEnough || points.size() < 8) { - return empty; - } - - const float rectangleScore = rectangleFitScore(points, bounds, closureDistance, diagonal); - const float ellipseScore = ellipseFitScore(points, bounds, closureDistance, diagonal); - - if (rectangleScore < 999.0f && (ellipseScore >= 999.0f || rectangleScore <= ellipseScore * 0.86f)) { - return makeRectangleCandidate(points, bounds); - } - - const PolygonRoughFit roughPolygonFit = roughPolygonFitForStroke(points, bounds, averageWidth); - const float roundMeanThreshold = std::max(averageWidth * 0.85f, diagonal * 0.018f); - const float roundMaxThreshold = std::max(averageWidth * 2.0f, diagonal * 0.050f); - const bool highConfidenceEllipse = - ellipseScore < 0.16f && - roughPolygonFit.vertexCount >= 8; - const bool smoothLoopFitsEllipse = - highConfidenceEllipse || - ( - ellipseScore < 0.24f && - roughPolygonFit.vertexCount >= 6 && - ( - roughPolygonFit.meanError >= roundMeanThreshold || - roughPolygonFit.maxError >= roundMaxThreshold - ) - ); - - if (smoothLoopFitsEllipse) { - return makeEllipseCandidate(points, bounds); - } - - ShapeCandidate polygonCandidate = makePolygonCandidate(points, bounds, averageWidth); - if (polygonCandidate.recognized) { - return polygonCandidate; - } - - if (ellipseScore < 999.0f) { - return makeEllipseCandidate(points, bounds); - } - - return empty; -} - -} // namespace - -SkiaDrawingEngine::SkiaDrawingEngine(int width, int height) - : width_(width) - , height_(height) - , currentTool_("pen") - , eraserMode_("pixel") - , needsStrokeRedraw_(true) - , needsEraserMaskRedraw_(true) - , hasLastSmoothedPoint_(false) - , eraserCursorX_(0) - , eraserCursorY_(0) - , eraserCursorRadius_(0) - , showEraserCursor_(false) - , cachedEraserCircleCount_(0) - , bakedCircleCount_(0) - , maxAffectedStrokeIndex_(0) - , backgroundType_("plain") - , selection_(std::make_unique()) - , backgroundRenderer_(std::make_unique()) - , serializer_(std::make_unique()) - , pathRenderer_(std::make_unique()) - , eraserRenderer_(std::make_unique()) - , strokeSplitter_(std::make_unique(pathRenderer_.get())) - , batchExporter_(std::make_unique(width, height)) - , activeStrokeRenderer_(std::make_unique(width, height, pathRenderer_.get())) { - - // Initialize paint with high-quality settings - currentPaint_.setAntiAlias(true); - currentPaint_.setStyle(SkPaint::kStroke_Style); - currentPaint_.setStrokeWidth(3.0f); - currentPaint_.setColor(SK_ColorBLACK); - currentPaint_.setStrokeCap(SkPaint::kRound_Cap); - currentPaint_.setStrokeJoin(SkPaint::kRound_Join); - currentPaint_.setDither(false); - - // Create offscreen surface for strokes only (transparent background) - SkImageInfo info = SkImageInfo::MakeN32Premul(width, height); - strokeSurface_ = SkSurfaces::Raster(info); - - if (strokeSurface_) { - strokeSurface_->getCanvas()->clear(SK_ColorTRANSPARENT); - cachedStrokeSnapshot_ = strokeSurface_->makeImageSnapshot(); // Initial empty snapshot - } - - // Create eraser mask surface (full RGBA for proper DstIn compositing) - eraserMaskSurface_ = SkSurfaces::Raster(info); - if (eraserMaskSurface_) { - eraserMaskSurface_->getCanvas()->clear(SK_ColorWHITE); - } - - // History starts empty -- canUndo/canRedo are derived from the - // stacks' emptiness so no sentinel is needed. -} - -SkiaDrawingEngine::~SkiaDrawingEngine() = default; - -void SkiaDrawingEngine::commitDelta(StrokeDelta&& delta) { - // Any new operation invalidates the redo timeline. - redoStack_.clear(); - undoStack_.push_back(std::move(delta)); - // Cap depth: drop the oldest entry. Each entry is O(opSize) so the - // cap is purely about bounding the number of undo levels, not - // memory pressure (delta sizes don't grow with stroke count). - while (undoStack_.size() > MAX_HISTORY_ENTRIES) { - undoStack_.erase(undoStack_.begin()); - } -} - -void SkiaDrawingEngine::recordPixelEraseCircleAdded(size_t strokeIndex, const EraserCircle& circle) { - // Find an existing entry for this stroke in the in-flight pixel - // erase delta, or create one. Most eraser drags touch a small set - // of strokes repeatedly so the linear scan is cheap. - for (auto& entry : pendingPixelEraseEntries_) { - if (entry.strokeIndex == strokeIndex) { - entry.addedCircles.push_back(circle); - return; - } - } - StrokeDelta::PixelEraseEntry entry; - entry.strokeIndex = strokeIndex; - entry.addedCircles.push_back(circle); - pendingPixelEraseEntries_.push_back(std::move(entry)); -} - -void SkiaDrawingEngine::applyDelta(const StrokeDelta& delta) { - // Forward direction (used by redo). - switch (delta.kind) { - case StrokeDelta::Kind::AddStrokes: { - for (const auto& s : delta.addedStrokes) { - strokes_.push_back(s); - } - break; - } - case StrokeDelta::Kind::RemoveStrokes: { - // Erase in DESCENDING order so each erase doesn't - // invalidate the indices of yet-to-process entries. - for (auto it = delta.removedStrokes.rbegin(); it != delta.removedStrokes.rend(); ++it) { - if (it->first < strokes_.size()) { - strokes_.erase(strokes_.begin() + static_cast(it->first)); - } - } - break; - } - case StrokeDelta::Kind::PixelErase: { - for (const auto& entry : delta.pixelEraseEntries) { - if (entry.strokeIndex >= strokes_.size()) continue; - auto& target = strokes_[entry.strokeIndex].erasedBy; - for (const auto& c : entry.addedCircles) target.push_back(c); - strokes_[entry.strokeIndex].cachedEraserCount = 0; // invalidate - } - break; - } - case StrokeDelta::Kind::MoveStrokes: { - for (size_t idx : delta.moveIndices) { - if (idx >= strokes_.size()) continue; - auto& s = strokes_[idx]; - for (auto& p : s.points) { p.x += delta.moveDx; p.y += delta.moveDy; } - for (auto& c : s.erasedBy) { c.x += delta.moveDx; c.y += delta.moveDy; } - pathRenderer_->smoothPath(s.points, s.path); - s.cachedEraserCount = 0; // path-stamped circles need re-render - } - break; - } - case StrokeDelta::Kind::ReplaceStrokes: { - for (const auto& [idx, stroke] : delta.afterStrokes) { - if (idx < strokes_.size()) { - strokes_[idx] = stroke; - } - } - break; - } - case StrokeDelta::Kind::Clear: { - strokes_.clear(); - eraserCircles_.clear(); - break; - } - } -} - -void SkiaDrawingEngine::revertDelta(const StrokeDelta& delta) { - // Backward direction (used by undo). - switch (delta.kind) { - case StrokeDelta::Kind::AddStrokes: { - // Pop the same number we appended. Caller invariant: redo - // didn't run other ops in between (we cleared redo on commit - // and applied/reverted strictly in stack order). - for (size_t i = 0; i < delta.addedStrokes.size(); ++i) { - if (!strokes_.empty()) strokes_.pop_back(); - } - break; - } - case StrokeDelta::Kind::RemoveStrokes: { - // Re-insert in ASCENDING order. Earlier insertions shift - // later targets into the right places. - for (const auto& [idx, stroke] : delta.removedStrokes) { - size_t clamped = std::min(idx, strokes_.size()); - strokes_.insert(strokes_.begin() + static_cast(clamped), stroke); - } - break; - } - case StrokeDelta::Kind::PixelErase: { - for (const auto& entry : delta.pixelEraseEntries) { - if (entry.strokeIndex >= strokes_.size()) continue; - auto& target = strokes_[entry.strokeIndex].erasedBy; - for (size_t i = 0; i < entry.addedCircles.size() && !target.empty(); ++i) { - target.pop_back(); - } - strokes_[entry.strokeIndex].cachedEraserCount = 0; - } - break; - } - case StrokeDelta::Kind::MoveStrokes: { - for (size_t idx : delta.moveIndices) { - if (idx >= strokes_.size()) continue; - auto& s = strokes_[idx]; - for (auto& p : s.points) { p.x -= delta.moveDx; p.y -= delta.moveDy; } - for (auto& c : s.erasedBy) { c.x -= delta.moveDx; c.y -= delta.moveDy; } - pathRenderer_->smoothPath(s.points, s.path); - s.cachedEraserCount = 0; - } - break; - } - case StrokeDelta::Kind::ReplaceStrokes: { - for (const auto& [idx, stroke] : delta.beforeStrokes) { - if (idx < strokes_.size()) { - strokes_[idx] = stroke; - } - } - break; - } - case StrokeDelta::Kind::Clear: { - strokes_ = delta.clearedStrokes; - eraserCircles_ = delta.clearedEraserCircles; - break; - } - } -} - -void SkiaDrawingEngine::touchBegan( - float x, - float y, - float pressure, - float azimuth, - float altitude, - long timestamp, - bool isPencilInput -) { - std::lock_guard lock(stateMutex_); - - currentPoints_.clear(); - currentPath_.reset(); - predictedPointCount_ = 0; // Reset prediction tracking for new stroke - clearActiveShapePreview(); - - // Reset incremental active stroke state - activeStrokeRenderer_->reset(); - - const bool enhancedPenProfile = usesEnhancedPenProfile(currentTool_, isPencilInput); - currentStrokeUsesEnhancedPenProfile_ = enhancedPenProfile; - float baseWidth = currentPaint_.getStrokeWidth(); - float calculatedWidth = pathRenderer_->calculateWidth( - pressure, - altitude, - baseWidth, - currentTool_, - enhancedPenProfile - ); - - Point p = {x, y, pressure, azimuth, altitude, calculatedWidth, timestamp}; - currentPoints_.push_back(p); - - // Initialize smoothing with first point - lastSmoothedPoint_ = p; - hasLastSmoothedPoint_ = true; - - currentPath_.moveTo(x, y); - - // Start lasso selection if tool is select - if (currentTool_ == "select") { - selection_->lassoBegin(x, y); - } -} - -void SkiaDrawingEngine::touchMoved( - float x, - float y, - float pressure, - float azimuth, - float altitude, - long timestamp, - bool isPencilInput -) { - std::lock_guard lock(stateMutex_); - - if (currentPoints_.empty()) return; - - if (hasActiveShapePreview_) { - const Point& endpoint = currentPoints_.back(); - const float dxFromEndpoint = x - endpoint.x; - const float dyFromEndpoint = y - endpoint.y; - const float preserveRadius = std::max(6.0f, currentPaint_.getStrokeWidth() * 1.25f); - if (dxFromEndpoint * dxFromEndpoint + dyFromEndpoint * dyFromEndpoint <= preserveRadius * preserveRadius) { - return; - } - - if (updateActiveShapePreviewForPoint(x, y)) { - return; - } - - clearActiveShapePreview(); - activeStrokeRenderer_->reset(); - } - - const bool enhancedPenProfile = usesEnhancedPenProfile(currentTool_, isPencilInput); - const float coordinateSmoothingFactor = enhancedPenProfile - ? kPenCoordinateSmoothing - : kDefaultCoordinateSmoothing; - const float pressureSmoothingFactor = enhancedPenProfile - ? kPenPressureSmoothing - : kDefaultPressureSmoothing; - const float altitudeSmoothingFactor = enhancedPenProfile - ? kPenAltitudeSmoothing - : kDefaultAltitudeSmoothing; - - float smoothedX; - float smoothedY; - float smoothedPressure; - float smoothedAltitude; - - if (hasLastSmoothedPoint_) { - smoothedX = interpolateValue(lastSmoothedPoint_.x, x, coordinateSmoothingFactor); - smoothedY = interpolateValue(lastSmoothedPoint_.y, y, coordinateSmoothingFactor); - smoothedPressure = interpolateValue(lastSmoothedPoint_.pressure, pressure, pressureSmoothingFactor); - smoothedAltitude = interpolateValue(lastSmoothedPoint_.altitude, altitude, altitudeSmoothingFactor); - } else { - smoothedX = x; - smoothedY = y; - smoothedPressure = pressure; - smoothedAltitude = altitude; - } - - // Point decimation - skip points too close together - const float minDistanceRealtime = pointDecimationDistance(currentTool_, isPencilInput); - if (!currentPoints_.empty()) { - const Point& last = currentPoints_.back(); - float dx = smoothedX - last.x; - float dy = smoothedY - last.y; - float distSq = dx * dx + dy * dy; - - if (distSq < minDistanceRealtime * minDistanceRealtime) { - float baseWidth = currentPaint_.getStrokeWidth(); - float calculatedWidth = pathRenderer_->calculateWidth( - smoothedPressure, - smoothedAltitude, - baseWidth, - currentTool_, - enhancedPenProfile - ); - - if (enhancedPenProfile && hasLastSmoothedPoint_) { - calculatedWidth = limitWidthDelta(lastSmoothedPoint_.calculatedWidth, calculatedWidth, baseWidth); - } - - Point p = { - smoothedX, - smoothedY, - smoothedPressure, - azimuth, - smoothedAltitude, - calculatedWidth, - timestamp - }; - lastSmoothedPoint_ = p; - hasLastSmoothedPoint_ = true; - return; - } - } - - float baseWidth = currentPaint_.getStrokeWidth(); - float calculatedWidth = pathRenderer_->calculateWidth( - smoothedPressure, - smoothedAltitude, - baseWidth, - currentTool_, - enhancedPenProfile - ); - - if (enhancedPenProfile && hasLastSmoothedPoint_) { - calculatedWidth = limitWidthDelta(lastSmoothedPoint_.calculatedWidth, calculatedWidth, baseWidth); - } - - Point p = { - smoothedX, - smoothedY, - smoothedPressure, - azimuth, - smoothedAltitude, - calculatedWidth, - timestamp - }; - currentPoints_.push_back(p); - - lastSmoothedPoint_ = p; - hasLastSmoothedPoint_ = true; - - // Object Eraser Logic: Check for intersections during move - if (currentTool_ == "eraser" && eraserMode_ == "object") { - SkRect eraserRect = SkRect::MakeXYWH(x - 10, y - 10, 20, 20); - - for (size_t i = 0; i < strokes_.size(); ++i) { - if (pendingDeleteIndices_.count(i) > 0) continue; - if (strokes_[i].isEraser) continue; - - if (strokes_[i].path.getBounds().intersects(eraserRect)) { - pendingDeleteIndices_.insert(i); - } - } - // OPTIMIZATION: Don't set needsStrokeRedraw_ - pending delete preview rendered directly - } - - // Pixel Eraser Logic: PencilKit-style immediate stroke splitting - if (currentTool_ == "eraser" && eraserMode_ == "pixel") { - float eraserRadius = currentPaint_.getStrokeWidth() / 2.0f; - - // Apply eraser and split strokes immediately - no eraserCircles_ needed - applyPixelEraserAt(p.x, p.y, eraserRadius); - } - - // Selection Tool Logic: Build lasso path during drag - if (currentTool_ == "select") { - selection_->lassoMove(x, y); - // Actual selection happens on touchEnded, not during drag - } -} - -void SkiaDrawingEngine::touchEnded(long timestamp) { - std::lock_guard lock(stateMutex_); - - if (currentPoints_.empty()) return; - - if (currentTool_ == "eraser" && eraserMode_ == "object") { - eraseObjects(); - } else if (currentTool_ == "eraser" && eraserMode_ == "pixel") { - // Commit a single PixelErase delta covering every (stroke, circle) - // pair that was added during this drag. pendingPixelEraseEntries_ - // was populated incrementally by recordPixelEraseCircleAdded - // during applyPixelEraserAt calls. Reset for the next drag. - if (!pendingPixelEraseEntries_.empty()) { - StrokeDelta delta; - delta.kind = StrokeDelta::Kind::PixelErase; - delta.pixelEraseEntries = std::move(pendingPixelEraseEntries_); - commitDelta(std::move(delta)); - } - pendingPixelEraseEntries_.clear(); - - // DON'T set needsStrokeRedraw_ - kClear visual is already correct - // Full redraw only needed on undo/redo/deserialize - - // Reset eraser position tracking for next stroke - hasLastEraserPoint_ = false; - - currentPoints_.clear(); - currentPath_.reset(); - } else if (currentTool_ == "select") { - // Finalize lasso selection - select strokes inside/intersecting the lasso - selection_->lassoEnd(strokes_, selectedIndices_); - // Pre-cache for smooth drag start - if (!selectedIndices_.empty()) { - prepareSelectionDragCache(); - } - currentPoints_.clear(); - currentPath_.reset(); - } else { - finishStroke(timestamp); - } - - hasLastSmoothedPoint_ = false; - predictedPointCount_ = 0; // Reset prediction tracking - currentStrokeUsesEnhancedPenProfile_ = false; -} - -bool SkiaDrawingEngine::updateHoldShapePreview(long timestamp) { - std::lock_guard lock(stateMutex_); - - if (currentPoints_.empty() || currentTool_ == "select" || currentTool_ == "eraser") { - clearActiveShapePreview(); - return false; - } - - ShapeCandidate candidate = recognizeHeldShape(currentPoints_, currentTool_, timestamp); - if (!candidate.recognized) { - return false; - } - - snapRecognizedShapeCandidateToStrokes(candidate, strokes_, averageCalculatedWidth(currentPoints_)); - - activeShapePreviewToolType_ = std::move(candidate.toolType); - activeShapePreviewPoints_ = std::move(candidate.points); - activeShapePreviewBasePoints_ = activeShapePreviewPoints_; - buildRecognizedShapePath(activeShapePreviewToolType_, activeShapePreviewPoints_, activeShapePreviewPath_); - hasActiveShapePreview_ = true; - activeShapePreviewAnchorPoint_ = activeShapePreviewToolType_ == "shape-line" - ? activeShapePreviewPoints_.front() - : centerPointForPoints(activeShapePreviewPoints_); - hasActiveShapePreviewAnchor_ = !activeShapePreviewPoints_.empty(); - activeShapePreviewReferenceAngle_ = std::atan2( - currentPoints_.back().y - activeShapePreviewAnchorPoint_.y, - currentPoints_.back().x - activeShapePreviewAnchorPoint_.x - ); - activeShapePreviewReferenceDistance_ = std::max( - 1.0f, - distanceBetween(activeShapePreviewAnchorPoint_, currentPoints_.back()) - ); - - // Once the shape is previewing, the cached freehand active surface should - // stop contributing pixels; the source stroke points remain intact for - // final recognition or for continuing freehand if the user moves again. - activeStrokeRenderer_->reset(); - - return true; -} - -bool SkiaDrawingEngine::updateActiveShapePreviewForPoint(float x, float y) { - if (!hasActiveShapePreview_ - || !hasActiveShapePreviewAnchor_ - || activeShapePreviewBasePoints_.empty()) { - return false; - } - - std::vector transformed = - (activeShapePreviewToolType_ == "shape-line" || activeShapePreviewToolType_ == "shape-circle") - ? transformedShapePoints( - activeShapePreviewBasePoints_, - activeShapePreviewAnchorPoint_, - activeShapePreviewReferenceAngle_, - activeShapePreviewReferenceDistance_, - x, - y, - 1.0f, - activeShapePreviewToolType_ == "shape-line" ? 1.0f : 0.0f - ) - : transformedShapePointsCenterLockedToTarget( - activeShapePreviewBasePoints_, - activeShapePreviewAnchorPoint_, - activeShapePreviewReferenceAngle_, - activeShapePreviewReferenceDistance_, - x, - y - ); - - if (transformed.size() < 2) { - return false; - } - - ShapeCandidate candidate; - candidate.recognized = true; - candidate.toolType = activeShapePreviewToolType_; - candidate.points = std::move(transformed); - snapRecognizedShapeCandidateToStrokes(candidate, strokes_, averageCalculatedWidth(candidate.points)); - - SkPath path; - if (!buildRecognizedShapePath(candidate.toolType, candidate.points, path)) { - return false; - } - - activeShapePreviewPoints_ = std::move(candidate.points); - activeShapePreviewPath_ = path; - activeStrokeRenderer_->reset(); - return true; -} - -// PREDICTIVE TOUCH: Clear predicted points before adding new actual data -// Called at the start of each touchMoved to discard old predictions -void SkiaDrawingEngine::clearPredictedPoints() { - std::lock_guard lock(stateMutex_); - - if (predictedPointCount_ > 0 && currentPoints_.size() >= predictedPointCount_) { - currentPoints_.erase( - currentPoints_.end() - predictedPointCount_, - currentPoints_.end() - ); - predictedPointCount_ = 0; - } -} - -// PREDICTIVE TOUCH: Add a predicted point from Apple Pencil -// These points are rendered but will be replaced by actual data on next touch event -void SkiaDrawingEngine::addPredictedPoint( - float x, - float y, - float pressure, - float azimuth, - float altitude, - long timestamp, - bool isPencilInput -) { - std::lock_guard lock(stateMutex_); - - if (currentPoints_.empty()) return; - - const bool enhancedPenProfile = usesEnhancedPenProfile(currentTool_, isPencilInput); - const float coordinateSmoothingFactor = enhancedPenProfile - ? kPenCoordinateSmoothing - : kDefaultCoordinateSmoothing; - const float pressureSmoothingFactor = enhancedPenProfile - ? kPenPressureSmoothing - : kDefaultPressureSmoothing; - const float altitudeSmoothingFactor = enhancedPenProfile - ? kPenAltitudeSmoothing - : kDefaultAltitudeSmoothing; - const Point& lastPoint = currentPoints_.back(); - - float smoothedX = interpolateValue(lastPoint.x, x, coordinateSmoothingFactor); - float smoothedY = interpolateValue(lastPoint.y, y, coordinateSmoothingFactor); - float smoothedPressure = interpolateValue(lastPoint.pressure, pressure, pressureSmoothingFactor); - float smoothedAltitude = interpolateValue(lastPoint.altitude, altitude, altitudeSmoothingFactor); - - float baseWidth = currentPaint_.getStrokeWidth(); - float calculatedWidth = pathRenderer_->calculateWidth( - smoothedPressure, - smoothedAltitude, - baseWidth, - currentTool_, - enhancedPenProfile - ); - - if (enhancedPenProfile) { - calculatedWidth = limitWidthDelta(lastPoint.calculatedWidth, calculatedWidth, baseWidth); - } - - Point p = { - smoothedX, - smoothedY, - smoothedPressure, - azimuth, - smoothedAltitude, - calculatedWidth, - timestamp - }; - currentPoints_.push_back(p); - predictedPointCount_++; -} - -void SkiaDrawingEngine::finishStroke(long endTimestamp) { - if (currentPoints_.size() < 2) { - currentPoints_.clear(); - currentPath_.reset(); - clearActiveShapePreview(); - currentStrokeUsesEnhancedPenProfile_ = false; - return; - } - - ShapeCandidate shapeCandidate; - if (hasActiveShapePreview_) { - shapeCandidate.recognized = true; - shapeCandidate.toolType = activeShapePreviewToolType_; - shapeCandidate.points = activeShapePreviewPoints_; - } else { - shapeCandidate = recognizeHeldShape(currentPoints_, currentTool_, endTimestamp); - } - - snapRecognizedShapeCandidateToStrokes(shapeCandidate, strokes_, averageCalculatedWidth(currentPoints_)); - - Stroke stroke; - stroke.points = shapeCandidate.recognized ? shapeCandidate.points : currentPoints_; - stroke.paint = currentPaint_; - stroke.isEraser = (currentTool_ == "eraser" && eraserMode_ == "pixel"); - stroke.toolType = shapeCandidate.recognized - ? shapeCandidate.toolType - : currentTool_; // Store tool type for specialized rendering - - // Keep Apple Pencil pen strokes fully opaque so anti-aliased edges stay - // crisp after the stroke is finalized. Other tools keep their existing - // opacity behavior. - if (!stroke.points.empty()) { - if (currentStrokeUsesEnhancedPenProfile_ - || currentTool_ == "highlighter" - || currentTool_ == "marker" - || currentTool_ == "crayon") { - stroke.originalAlphaMod = 1.0f; - } else { - float avgPressure = 0.0f; - for (const auto& pt : stroke.points) { - avgPressure += pt.pressure; - } - avgPressure /= stroke.points.size(); - stroke.originalAlphaMod = 0.85f + (avgPressure * 0.15f); // Subtler range: 85%-100% - } - } - - if (!buildRecognizedShapePath(stroke.toolType, stroke.points, stroke.path)) { - smoothPath(stroke.points, stroke.path); - } - - // Calculate and cache path length for eraser operations - SkPathMeasure pathMeasure(stroke.path, false); - stroke.pathLength = pathMeasure.getLength(); - - // If this is an eraser stroke, record which strokes it affects - if (stroke.isEraser) { - SkRect eraserBounds = stroke.path.getBounds(); - for (size_t i = 0; i < strokes_.size(); ++i) { - if (strokes_[i].isEraser) continue; - - SkRect strokeBounds = strokes_[i].path.getBounds(); - if (strokeBounds.intersects(eraserBounds)) { - stroke.affectedStrokeIndices.insert(i); - } - } - } - - strokes_.push_back(stroke); - - // === FAST PATH: Composite active stroke directly === - // This preserves O(1) stroke completion for smooth performance. - if (strokeSurface_ && currentTool_ != "eraser") { - SkCanvas* strokeCanvas = strokeSurface_->getCanvas(); - - if (isRecognizedShapeToolType(stroke.toolType)) { - SkPaint strokePaint = stroke.paint; - if (!stroke.isEraser) { - uint8_t baseAlpha = stroke.paint.getAlpha(); - strokePaint.setAlpha(static_cast(baseAlpha * stroke.originalAlphaMod)); - } - renderStrokeGeometry(strokeCanvas, stroke, strokePaint); - } else { - // Render any remaining tail points to complete the stroke - if (currentPoints_.size() > activeStrokeRenderer_->getLastRenderedIndex()) { - activeStrokeRenderer_->renderFinalTail(currentPoints_, currentPaint_, currentTool_); - } - - // Composite active stroke onto persistent stroke surface - sk_sp activeImage = activeStrokeRenderer_->getSnapshot(); - if (activeImage) { - strokeCanvas->drawImage(activeImage, 0, 0); - } - } - - // Update cached snapshot - cachedStrokeSnapshot_ = strokeSurface_->makeImageSnapshot(); - - // Track that we have composited strokes (for consistency check on tool switch) - maxAffectedStrokeIndex_ = strokes_.size(); - } else { - // Fallback for eraser or if surfaces unavailable - needsStrokeRedraw_ = true; - } - - // Single-stroke append. The new stroke is the last element in - // strokes_ (just push_back'd above). Capture it for the delta so - // undo can pop_back and redo can push_back from delta storage. - { - StrokeDelta delta; - delta.kind = StrokeDelta::Kind::AddStrokes; - delta.addedStrokes.push_back(strokes_.back()); - commitDelta(std::move(delta)); - } - - // Clean up incremental active stroke rendering state - activeStrokeRenderer_->reset(); - - currentPoints_.clear(); - currentPath_.reset(); - clearActiveShapePreview(); - currentStrokeUsesEnhancedPenProfile_ = false; -} - -void SkiaDrawingEngine::eraseObjects() { - std::lock_guard lock(stateMutex_); - - // Helper lambda to remove strokes and update eraser references - auto removeStrokes = [this](const std::unordered_set& indicesToRemove) { - // Capture (idx, stroke) pairs for the removed entries BEFORE - // mutating strokes_. The delta needs them so undo can re-insert - // at the original indices. Sorted ascending. - std::vector sortedIndices(indicesToRemove.begin(), indicesToRemove.end()); - std::sort(sortedIndices.begin(), sortedIndices.end()); - StrokeDelta delta; - delta.kind = StrokeDelta::Kind::RemoveStrokes; - delta.removedStrokes.reserve(sortedIndices.size()); - for (size_t idx : sortedIndices) { - if (idx < strokes_.size()) { - delta.removedStrokes.emplace_back(idx, strokes_[idx]); - } - } - - std::unordered_map oldToNew; - size_t newIdx = 0; - std::vector remaining; - remaining.reserve(strokes_.size() - indicesToRemove.size()); - - for (size_t i = 0; i < strokes_.size(); ++i) { - if (indicesToRemove.count(i) == 0) { - oldToNew[i] = newIdx++; - remaining.push_back(strokes_[i]); - } - } - for (auto& s : remaining) { - if (s.isEraser) { - std::unordered_set updated; - for (size_t old : s.affectedStrokeIndices) - if (oldToNew.count(old)) updated.insert(oldToNew[old]); - s.affectedStrokeIndices = updated; - } - } - if (remaining.size() != strokes_.size()) { - strokes_ = remaining; - commitDelta(std::move(delta)); - needsStrokeRedraw_ = true; - } - }; - - if (!pendingDeleteIndices_.empty()) { - removeStrokes(pendingDeleteIndices_); - pendingDeleteIndices_.clear(); - currentPoints_.clear(); - currentPath_.reset(); - return; - } - - if (currentPoints_.empty()) return; - - // Build eraser bounds from current points - SkRect eraserBounds = SkRect::MakeXYWH(currentPoints_[0].x, currentPoints_[0].y, 0, 0); - for (const auto& pt : currentPoints_) { - eraserBounds.join(SkRect::MakeXYWH(pt.x, pt.y, 0, 0)); - } - eraserBounds.outset(currentPaint_.getStrokeWidth() / 2.0f, currentPaint_.getStrokeWidth() / 2.0f); - - std::unordered_set toRemove; - for (size_t i = 0; i < strokes_.size(); ++i) { - if (strokes_[i].path.getBounds().intersects(eraserBounds)) toRemove.insert(i); - } - removeStrokes(toRemove); - currentPoints_.clear(); - currentPath_.reset(); -} - -void SkiaDrawingEngine::smoothPath(const std::vector& points, SkPath& path) { - pathRenderer_->smoothPath(points, path); -} - -void SkiaDrawingEngine::clearActiveShapePreview() { - hasActiveShapePreview_ = false; - activeShapePreviewToolType_.clear(); - activeShapePreviewPoints_.clear(); - activeShapePreviewBasePoints_.clear(); - activeShapePreviewPath_.reset(); - hasActiveShapePreviewAnchor_ = false; - activeShapePreviewReferenceAngle_ = 0.0f; - activeShapePreviewReferenceDistance_ = 1.0f; -} - -void SkiaDrawingEngine::clear() { - std::lock_guard lock(stateMutex_); - - // Capture the pre-clear state for the delta BEFORE wiping. Clear is - // the one operation that genuinely needs an O(N) snapshot to support - // undo, but it happens once per clear (not per stroke), so the cost - // is bounded. - StrokeDelta delta; - delta.kind = StrokeDelta::Kind::Clear; - delta.clearedStrokes = strokes_; - delta.clearedEraserCircles = eraserCircles_; - - strokes_.clear(); - eraserCircles_.clear(); - currentPoints_.clear(); - currentPath_.reset(); - clearActiveShapePreview(); - activeStrokeRenderer_->reset(); // Clear any in-progress incremental rendering - bakedCircleCount_ = 0; // No circles to bake - - commitDelta(std::move(delta)); - - needsStrokeRedraw_ = true; - needsEraserMaskRedraw_ = true; -} - -void SkiaDrawingEngine::undo() { - std::lock_guard lock(stateMutex_); - - if (undoStack_.empty()) return; - StrokeDelta delta = std::move(undoStack_.back()); - undoStack_.pop_back(); - revertDelta(delta); - redoStack_.push_back(std::move(delta)); - - cachedEraserCircleCount_ = 0; - bakedCircleCount_ = 0; - clearActiveShapePreview(); - activeStrokeRenderer_->reset(); - needsStrokeRedraw_ = true; - needsEraserMaskRedraw_ = true; -} - -void SkiaDrawingEngine::redo() { - std::lock_guard lock(stateMutex_); - - if (redoStack_.empty()) return; - StrokeDelta delta = std::move(redoStack_.back()); - redoStack_.pop_back(); - applyDelta(delta); - undoStack_.push_back(std::move(delta)); - - cachedEraserCircleCount_ = 0; - bakedCircleCount_ = 0; - clearActiveShapePreview(); - activeStrokeRenderer_->reset(); - needsStrokeRedraw_ = true; - needsEraserMaskRedraw_ = true; -} - -void SkiaDrawingEngine::setStrokeColor(SkColor color) { - std::lock_guard lock(stateMutex_); - - if (currentTool_ == "eraser") return; - uint8_t a = SkColorGetA(currentPaint_.getColor()); - currentPaint_.setColor(SkColorSetARGB(a, SkColorGetR(color), SkColorGetG(color), SkColorGetB(color))); -} - -void SkiaDrawingEngine::setStrokeWidth(float width) { - std::lock_guard lock(stateMutex_); - - currentPaint_.setStrokeWidth(width); -} - -void SkiaDrawingEngine::setToolWithParams(const char* toolType, float width, uint32_t color, const char* eraserMode) { - std::lock_guard lock(stateMutex_); - - setTool(toolType); - currentPaint_.setStrokeWidth(width); - - if (toolType && std::string(toolType) != "eraser") { - // Extract RGB from color, but ALWAYS preserve tool's alpha (set by setTool) - uint8_t toolAlpha = SkColorGetA(currentPaint_.getColor()); - uint8_t r = (color >> 16) & 0xFF; - uint8_t g = (color >> 8) & 0xFF; - uint8_t b = color & 0xFF; - currentPaint_.setColor(SkColorSetARGB(toolAlpha, b, g, r)); // Swap R and B for platform - } - eraserMode_ = (eraserMode && std::strlen(eraserMode) > 0) ? eraserMode : "pixel"; -} - -void SkiaDrawingEngine::setTool(const char* toolType) { - std::lock_guard lock(stateMutex_); - - currentTool_ = toolType; - std::string tool(toolType); - - currentPaint_.setAntiAlias(true); - currentPaint_.setDither(false); - - if (tool == "pen") { - currentPaint_.setAlpha(255); - currentPaint_.setStrokeCap(SkPaint::kRound_Cap); - currentPaint_.setStrokeJoin(SkPaint::kRound_Join); - currentPaint_.setBlendMode(SkBlendMode::kSrcOver); - } else if (tool == "pencil") { - currentPaint_.setAlpha(200); - currentPaint_.setStrokeCap(SkPaint::kRound_Cap); - currentPaint_.setStrokeJoin(SkPaint::kRound_Join); - currentPaint_.setBlendMode(SkBlendMode::kSrcOver); - } else if (tool == "marker") { - currentPaint_.setAlpha(115); - currentPaint_.setStrokeCap(SkPaint::kRound_Cap); - currentPaint_.setStrokeJoin(SkPaint::kRound_Join); - currentPaint_.setBlendMode(SkBlendMode::kSrcOver); - } else if (tool == "highlighter") { - currentPaint_.setAlpha(140); // Higher base alpha for visibility - currentPaint_.setStrokeCap(SkPaint::kRound_Cap); - currentPaint_.setStrokeJoin(SkPaint::kRound_Join); - currentPaint_.setBlendMode(SkBlendMode::kMultiply); - } else if (tool == "eraser") { - currentPaint_.setAlpha(255); - currentPaint_.setStrokeCap(SkPaint::kRound_Cap); - currentPaint_.setStrokeJoin(SkPaint::kRound_Join); - currentPaint_.setBlendMode(SkBlendMode::kDstOut); - currentPaint_.setColor(SK_ColorBLACK); - } else if (tool == "crayon") { - // Crayon: Semi-transparent (~85%), waxy texture applied during rendering - currentPaint_.setAlpha(217); // ~85% opacity (217/255) - currentPaint_.setStrokeCap(SkPaint::kRound_Cap); - currentPaint_.setStrokeJoin(SkPaint::kRound_Join); - currentPaint_.setBlendMode(SkBlendMode::kSrcOver); - } else if (tool == "calligraphy") { - // Calligraphy: Fully opaque, smooth ink, flex nib behavior - // Width varies based on stroke direction (thin upstrokes, thick downstrokes) - currentPaint_.setAlpha(255); - currentPaint_.setStrokeCap(SkPaint::kRound_Cap); - currentPaint_.setStrokeJoin(SkPaint::kRound_Join); - currentPaint_.setBlendMode(SkBlendMode::kSrcOver); - } -} - -void SkiaDrawingEngine::setBackgroundType(const char* backgroundType) { - std::lock_guard lock(stateMutex_); - - backgroundType_ = backgroundType ? backgroundType : "plain"; -} - -std::string SkiaDrawingEngine::getBackgroundType() const { - std::lock_guard lock(stateMutex_); - return backgroundType_; -} - -void SkiaDrawingEngine::setPdfBackgroundImage(sk_sp image) { - std::lock_guard lock(stateMutex_); - - pdfBackgroundImage_ = image; -} - -void SkiaDrawingEngine::renderStrokeGeometry(SkCanvas* canvas, const Stroke& stroke, const SkPaint& paint) { - if (!canvas) { - return; - } - - if (isRecognizedShapeToolType(stroke.toolType)) { - SkPath shapePath = stroke.path; - if (shapePath.isEmpty()) { - buildRecognizedShapePath(stroke.toolType, stroke.points, shapePath); - } - - if (shapePath.isEmpty()) { - return; - } - - SkPaint shapePaint = paint; - shapePaint.setStyle(SkPaint::kStroke_Style); - shapePaint.setStrokeWidth(averageCalculatedWidth(stroke.points)); - shapePaint.setStrokeJoin(stroke.toolType == "shape-line" - ? SkPaint::kRound_Join - : SkPaint::kMiter_Join); - shapePaint.setStrokeCap(stroke.toolType == "shape-line" - ? SkPaint::kRound_Cap - : SkPaint::kButt_Cap); - canvas->drawPath(shapePath, shapePaint); - return; - } - - if (stroke.toolType == "crayon") { - pathRenderer_->drawCrayonPath(canvas, stroke.points, paint, false); - } else if (stroke.toolType == "calligraphy") { - pathRenderer_->drawCalligraphyPath(canvas, stroke.points, paint, false); - } else { - pathRenderer_->drawVariableWidthPath(canvas, stroke.points, paint, false); - } -} - -void SkiaDrawingEngine::redrawStrokes() { - if (!needsStrokeRedraw_) return; - - SkCanvas* canvas = strokeSurface_->getCanvas(); - canvas->clear(SK_ColorTRANSPARENT); - - // Helper to render a single stroke with per-stroke eraser clipping - auto renderStroke = [&](size_t i) { - const auto& stroke = strokes_[i]; - SkPaint strokePaint = stroke.paint; - - if (!stroke.isEraser) { - uint8_t baseAlpha = stroke.paint.getAlpha(); - strokePaint.setAlpha(static_cast(baseAlpha * stroke.originalAlphaMod)); - } - - if (pendingDeleteIndices_.count(i) > 0) { - strokePaint.setAlpha(strokePaint.getAlpha() * 0.3); - } - - // Apply per-stroke eraser clipping if this stroke has been erased - bool needsClipRestore = false; - if (!stroke.erasedBy.empty()) { - // Ensure cache is up-to-date (builds smooth capsule shapes between circles) - stroke.ensureEraserCacheValid(); - if (!stroke.cachedEraserPath.isEmpty()) { - // Clip out the erased regions (kDifference = draw everywhere EXCEPT cached path) - canvas->save(); - canvas->clipPath(stroke.cachedEraserPath, SkClipOp::kDifference); - needsClipRestore = true; - } - } - - // Render stroke (curves unchanged - clipping handles pixel-perfect erasure) - renderStrokeGeometry(canvas, stroke, strokePaint); - - if (needsClipRestore) { - canvas->restore(); - } - }; - - // Render all strokes - eraser effect applied via per-stroke clipping - for (size_t strokeIdx = 0; strokeIdx < strokes_.size(); ++strokeIdx) { - renderStroke(strokeIdx); - } - - // All strokes are now in strokeSurface_ - maxAffectedStrokeIndex_ = strokes_.size(); - - // Cache snapshot for fast rendering (avoid makeImageSnapshot every frame) - cachedStrokeSnapshot_ = strokeSurface_->makeImageSnapshot(); - - needsStrokeRedraw_ = false; -} - -void SkiaDrawingEngine::redrawEraserMask() { - if (!needsEraserMaskRedraw_) return; - - SkCanvas* canvas = eraserMaskSurface_->getCanvas(); - canvas->clear(SK_ColorWHITE); // Full alpha (255) = visible + // Calculate and cache path length for eraser operations + SkPathMeasure pathMeasure(stroke.path, false); + stroke.pathLength = pathMeasure.getLength(); - if (!eraserCircles_.empty()) { - SkPaint erasePaint; - erasePaint.setBlendMode(SkBlendMode::kClear); // Sets pixels to 0 alpha (transparent = erased) - erasePaint.setAntiAlias(true); - erasePaint.setStyle(SkPaint::kFill_Style); + // If this is an eraser stroke, record which strokes it affects + if (stroke.isEraser) { + SkRect eraserBounds = stroke.path.getBounds(); + for (size_t i = 0; i < strokes_.size(); ++i) { + if (strokes_[i].isEraser) continue; - // Build path from all circles (or use cached if available) - if (eraserCircles_.size() != cachedEraserCircleCount_) { - cachedEraserPath_.reset(); - for (const auto& circle : eraserCircles_) { - cachedEraserPath_.addCircle(circle.x, circle.y, circle.radius); + SkRect strokeBounds = strokes_[i].path.getBounds(); + if (strokeBounds.intersects(eraserBounds)) { + stroke.affectedStrokeIndices.insert(i); } - cachedEraserCircleCount_ = eraserCircles_.size(); } - - canvas->drawPath(cachedEraserPath_, erasePaint); } - needsEraserMaskRedraw_ = false; -} - -void SkiaDrawingEngine::render(SkCanvas* canvas) { - std::lock_guard lock(stateMutex_); - - // OPTIMIZATION: When dragging selection, use all cached snapshots - pure O(1) per frame - if (!selectedIndices_.empty() && isDraggingSelection_) { - // Draw cached background - O(1) - if (dragBackgroundSnapshot_) { - canvas->drawImage(dragBackgroundSnapshot_, 0, 0); - } - - // Draw cached non-selected strokes - O(1) - if (nonSelectedSnapshot_) { - canvas->drawImage(nonSelectedSnapshot_, 0, 0); - } + strokes_.push_back(stroke); - // Draw cached selected strokes with offset - O(1) - if (selectedSnapshot_) { - canvas->drawImage(selectedSnapshot_, selectionOffsetX_, selectionOffsetY_); - } - } else { - // Normal path: draw background - if (backgroundType_ == "pdf") { - if (pdfBackgroundImage_) { - canvas->clear(SK_ColorWHITE); - backgroundRenderer_->drawBackground( - canvas, - backgroundType_, - width_, - height_, - pdfBackgroundImage_, - backgroundOriginY_ - ); - } else { - canvas->clear(SK_ColorTRANSPARENT); - } - } else { - canvas->clear(SK_ColorWHITE); - backgroundRenderer_->drawBackground( - canvas, - backgroundType_, - width_, - height_, - pdfBackgroundImage_, - backgroundOriginY_ - ); - } + // === FAST PATH: Composite active stroke directly === + // This preserves O(1) stroke completion for smooth performance. + if (strokeSurface_ && currentTool_ != "eraser") { + SkCanvas* strokeCanvas = strokeSurface_->getCanvas(); - // Draw strokes - if (!selectedIndices_.empty() && !needsStrokeRedraw_) { - // Selection exists but not actively dragging - render all strokes directly - for (size_t i = 0; i < strokes_.size(); ++i) { - const auto& stroke = strokes_[i]; - SkPaint strokePaint = stroke.paint; - - if (!stroke.isEraser) { - uint8_t baseAlpha = stroke.paint.getAlpha(); - strokePaint.setAlpha(static_cast(baseAlpha * stroke.originalAlphaMod)); - } - - bool needsClipRestore = false; - if (!stroke.erasedBy.empty()) { - stroke.ensureEraserCacheValid(); - if (!stroke.cachedEraserPath.isEmpty()) { - canvas->save(); - canvas->clipPath(stroke.cachedEraserPath, SkClipOp::kDifference); - needsClipRestore = true; - } - } - - renderStrokeGeometry(canvas, stroke, strokePaint); - - if (needsClipRestore) { - canvas->restore(); - } + if (isRecognizedShapeToolType(stroke.toolType)) { + SkPaint strokePaint = stroke.paint; + if (!stroke.isEraser) { + uint8_t baseAlpha = stroke.paint.getAlpha(); + strokePaint.setAlpha(static_cast(baseAlpha * stroke.originalAlphaMod)); } + renderStrokeGeometry(strokeCanvas, stroke, strokePaint); } else { - // Normal path: use cached surface - redrawStrokes(); - - // OPTIMIZATION: If object eraser is active, clip out pending-delete strokes - if (!pendingDeleteIndices_.empty() && cachedStrokeSnapshot_) { - SkPath excludePath; - for (size_t idx : pendingDeleteIndices_) { - if (idx >= strokes_.size()) continue; - SkRect bounds = strokes_[idx].path.getBounds(); - bounds.outset(strokes_[idx].paint.getStrokeWidth(), strokes_[idx].paint.getStrokeWidth()); - excludePath.addRect(bounds); - } - - canvas->save(); - canvas->clipPath(excludePath, SkClipOp::kDifference); - canvas->drawImage(cachedStrokeSnapshot_, 0, 0); - canvas->restore(); - - for (size_t idx : pendingDeleteIndices_) { - if (idx >= strokes_.size()) continue; - const auto& stroke = strokes_[idx]; - - SkPaint dimPaint = stroke.paint; - uint8_t baseAlpha = stroke.paint.getAlpha(); - dimPaint.setAlpha(static_cast(baseAlpha * stroke.originalAlphaMod * 0.3f)); - - bool needsClipRestore = false; - if (!stroke.erasedBy.empty()) { - stroke.ensureEraserCacheValid(); - if (!stroke.cachedEraserPath.isEmpty()) { - canvas->save(); - canvas->clipPath(stroke.cachedEraserPath, SkClipOp::kDifference); - needsClipRestore = true; - } - } - - renderStrokeGeometry(canvas, stroke, dimPaint); - - if (needsClipRestore) { - canvas->restore(); - } - } - } else if (cachedStrokeSnapshot_) { - canvas->drawImage(cachedStrokeSnapshot_, 0, 0); + // Render any remaining tail points to complete the stroke + if (currentPoints_.size() > activeStrokeRenderer_->getLastRenderedIndex()) { + activeStrokeRenderer_->renderFinalTail(currentPoints_, currentPaint_, currentTool_); } - } - } - - // 4. Draw active stroke incrementally (O(1) per frame instead of O(n)) - if (hasActiveShapePreview_ && !activeShapePreviewPoints_.empty()) { - Stroke previewStroke; - previewStroke.points = activeShapePreviewPoints_; - previewStroke.paint = currentPaint_; - previewStroke.path = activeShapePreviewPath_; - previewStroke.toolType = activeShapePreviewToolType_; - - SkPaint previewPaint = currentPaint_; - if (currentTool_ != "highlighter" && currentTool_ != "marker") { - const float pressureAlphaMod = 0.85f + (averagePressure(currentPoints_) * 0.15f); - previewPaint.setAlpha(static_cast(previewPaint.getAlpha() * pressureAlphaMod)); - } - - renderStrokeGeometry(canvas, previewStroke, previewPaint); - } else if (currentPoints_.size() >= 2 && currentTool_ != "select" && currentTool_ != "eraser") { - activeStrokeRenderer_->renderIncremental(canvas, currentPoints_, currentPaint_, currentTool_); - } - - // Draw eraser cursor for pixel eraser - if (showEraserCursor_ && eraserCursorRadius_ > 0) { - SkPaint cursorPaint; - cursorPaint.setStyle(SkPaint::kStroke_Style); - cursorPaint.setColor(SkColorSetARGB(180, 128, 128, 128)); - cursorPaint.setStrokeWidth(2.0f); - cursorPaint.setAntiAlias(true); - - canvas->drawCircle(eraserCursorX_, eraserCursorY_, eraserCursorRadius_, cursorPaint); - } - - // Draw lasso path if active (during selection drag) - if (currentTool_ == "select") { - selection_->renderLasso(canvas); - } - - // Draw selection highlight if strokes are selected - if (isDraggingSelection_ && selectionHighlightSnapshot_) { - // During drag, use cached highlight with offset - O(1) - canvas->drawImage(selectionHighlightSnapshot_, selectionOffsetX_, selectionOffsetY_); - } else { - selection_->renderSelection(canvas, strokes_, selectedIndices_); - } -} - -sk_sp SkiaDrawingEngine::makeSnapshot() { - std::lock_guard lock(stateMutex_); - - SkImageInfo info = SkImageInfo::MakeN32Premul(width_, height_); - sk_sp surface = SkSurfaces::Raster(info); - render(surface->getCanvas()); - return surface->makeImageSnapshot(); -} - -std::vector SkiaDrawingEngine::batchExportPages( - const std::vector>& pagesData, - const std::vector& backgroundTypes, - const std::vector>& pdfBackgrounds, - const std::vector& pageIndices, - float scale -) { - std::lock_guard lock(stateMutex_); - - std::vector results; - results.reserve(pagesData.size()); - - int scaledWidth = static_cast(width_ * scale); - int scaledHeight = static_cast(height_ * scale); - SkImageInfo info = SkImageInfo::MakeN32Premul(scaledWidth, scaledHeight); - sk_sp exportSurface = SkSurfaces::Raster(info); - - if (!exportSurface) { - printf("[C++] batchExportPages: Failed to create export surface\n"); - return results; - } - // Save original state - auto originalStrokes = strokes_; - auto originalEraserCircles = eraserCircles_; - auto originalPdfBackground = pdfBackgroundImage_; - auto originalBackgroundType = backgroundType_; - float originalBackgroundOriginY = backgroundOriginY_; - auto originalUndoStack = undoStack_; - auto originalRedoStack = redoStack_; - - for (size_t i = 0; i < pagesData.size(); ++i) { - SkCanvas* canvas = exportSurface->getCanvas(); - canvas->clear(SK_ColorTRANSPARENT); - canvas->save(); - canvas->scale(scale, scale); - - backgroundType_ = (i < backgroundTypes.size() && !backgroundTypes[i].empty()) - ? backgroundTypes[i] : "plain"; - pdfBackgroundImage_ = (i < pdfBackgrounds.size()) ? pdfBackgrounds[i] : nullptr; - int pageIndex = (i < pageIndices.size()) ? pageIndices[i] : static_cast(i); - backgroundOriginY_ = std::max(0, pageIndex) * static_cast(height_); - - if (!pagesData[i].empty()) { - if (!deserializeDrawing(pagesData[i])) { - strokes_.clear(); - eraserCircles_.clear(); + // Composite active stroke onto persistent stroke surface + sk_sp activeImage = activeStrokeRenderer_->getSnapshot(); + if (activeImage) { + strokeCanvas->drawImage(activeImage, 0, 0); } - } else { - strokes_.clear(); - eraserCircles_.clear(); } - normalizeStrokeColorsForRasterExport(strokes_); + // Update cached snapshot + cachedStrokeSnapshot_ = strokeSurface_->makeImageSnapshot(); + // Track that we have composited strokes (for consistency check on tool switch) + maxAffectedStrokeIndex_ = strokes_.size(); + } else { + // Fallback for eraser or if surfaces unavailable needsStrokeRedraw_ = true; - needsEraserMaskRedraw_ = true; - render(canvas); - canvas->restore(); - - sk_sp snapshot = exportSurface->makeImageSnapshot(); - if (snapshot) { - sk_sp pngData = SkPngEncoder::Encode(nullptr, snapshot.get(), {}); - if (pngData) { - results.push_back("data:image/png;base64," + - BatchExporter::encodeBase64(pngData->data(), pngData->size())); - } else { - results.push_back(""); - } - } else { - results.push_back(""); - } - } - - // Restore original state - strokes_ = originalStrokes; - eraserCircles_ = originalEraserCircles; - cachedEraserCircleCount_ = 0; - pdfBackgroundImage_ = originalPdfBackground; - backgroundType_ = originalBackgroundType; - backgroundOriginY_ = originalBackgroundOriginY; - undoStack_ = std::move(originalUndoStack); - redoStack_ = std::move(originalRedoStack); - needsStrokeRedraw_ = true; - needsEraserMaskRedraw_ = true; - - return results; -} - -void SkiaDrawingEngine::setEraserCursor(float x, float y, float radius, bool visible) { - std::lock_guard lock(stateMutex_); - - eraserCursorX_ = x; - eraserCursorY_ = y; - eraserCursorRadius_ = radius; - showEraserCursor_ = visible; -} - -std::vector SkiaDrawingEngine::serializeDrawing() { - std::lock_guard lock(stateMutex_); - - return serializer_->serialize(strokes_); -} - -bool SkiaDrawingEngine::deserializeDrawing(const std::vector& data) { - std::lock_guard lock(stateMutex_); - - std::vector loadedStrokes; - if (!serializer_->deserialize(data, loadedStrokes)) { - printf("[C++] Failed to deserialize drawing payload safely\n"); - return false; - } - - strokes_ = std::move(loadedStrokes); - eraserCircles_.clear(); // Clear eraser circles when loading - bakedCircleCount_ = 0; // No circles to bake - selectedIndices_.clear(); - isDraggingSelection_ = false; - hasDragCache_ = false; - selectionOffsetX_ = 0.0f; - selectionOffsetY_ = 0.0f; - dragBackgroundSnapshot_ = nullptr; - nonSelectedSnapshot_ = nullptr; - selectedSnapshot_ = nullptr; - selectionHighlightSnapshot_ = nullptr; - // Reset history. Loading a serialized notebook is treated as a - // checkpoint -- the user wouldn't expect to undo past the load. - undoStack_.clear(); - redoStack_.clear(); - needsStrokeRedraw_ = true; - needsEraserMaskRedraw_ = true; - return true; -} - -bool SkiaDrawingEngine::canUndo() const { - std::lock_guard lock(stateMutex_); - - return !undoStack_.empty(); -} - -bool SkiaDrawingEngine::canRedo() const { - std::lock_guard lock(stateMutex_); - - return !redoStack_.empty(); -} - -bool SkiaDrawingEngine::isEmpty() const { - std::lock_guard lock(stateMutex_); - - return strokes_.empty(); -} - -// Selection operations - delegate to selection module -bool SkiaDrawingEngine::selectStrokeAt(float x, float y) { - std::lock_guard lock(stateMutex_); - - // Selection highlight is drawn directly to output canvas, no stroke redraw needed - bool changed = selection_->selectStrokeAt(x, y, strokes_, selectedIndices_); - if (changed) { - // Pre-cache for smooth drag start - prepareSelectionDragCache(); } - return changed; -} - -bool SkiaDrawingEngine::selectShapeStrokeAt(float x, float y) { - std::lock_guard lock(stateMutex_); - - // Finger tap selection is object-like: only recognized, snapped shape - // strokes should behave as tappable objects. Freehand handwriting remains - // selectable through the explicit select/lasso tool. - bool changed = selection_->selectShapeStrokeAt(x, y, strokes_, selectedIndices_); - if (changed) { - prepareSelectionDragCache(); - } - return changed; -} - -void SkiaDrawingEngine::clearSelection() { - std::lock_guard lock(stateMutex_); - - // Selection highlight is drawn directly to output canvas, no stroke redraw needed - if (!selectedIndices_.empty()) { - selection_->clearSelection(selectedIndices_); - endSelectionDrag(); // Free cached snapshots to prevent memory leak - } -} - -void SkiaDrawingEngine::deleteSelection() { - std::lock_guard lock(stateMutex_); - - auto commit = [this](StrokeDelta&& d) { commitDelta(std::move(d)); }; - selection_->deleteSelection(strokes_, selectedIndices_, commit); - needsStrokeRedraw_ = true; -} -void SkiaDrawingEngine::copySelection() { - std::lock_guard lock(stateMutex_); - - selection_->copySelection(strokes_, selectedIndices_, copiedStrokes_); -} - -void SkiaDrawingEngine::pasteSelection(float offsetX, float offsetY) { - std::lock_guard lock(stateMutex_); - - auto commit = [this](StrokeDelta&& d) { commitDelta(std::move(d)); }; - selection_->pasteSelection(strokes_, copiedStrokes_, offsetX, offsetY, commit); - needsStrokeRedraw_ = true; -} + // Single-stroke append. The new stroke is the last element in + // strokes_ (just push_back'd above). Capture it for the delta so + // undo can pop_back and redo can push_back from delta storage. + { + StrokeDelta delta; + delta.kind = StrokeDelta::Kind::AddStrokes; + delta.addedStrokes.push_back(strokes_.back()); + commitDelta(std::move(delta)); + } -void SkiaDrawingEngine::moveSelection(float dx, float dy) { - std::lock_guard lock(stateMutex_); + // Clean up incremental active stroke rendering state + activeStrokeRenderer_->reset(); - if (selectedIndices_.empty()) return; + currentPoints_.clear(); + currentPath_.reset(); + clearActiveShapePreview(); + currentStrokeUsesEnhancedPenProfile_ = false; +} - if (!isDraggingSelection_) { - beginSelectionDrag(); - } +void SkiaDrawingEngine::smoothPath(const std::vector& points, SkPath& path) { + pathRenderer_->smoothPath(points, path); +} - // OPTIMIZATION: Just accumulate offset - O(1), no path/point modification - // Selected strokes are rendered from cached snapshot with offset - selectionOffsetX_ += dx; - selectionOffsetY_ += dy; +void SkiaDrawingEngine::clearActiveShapePreview() { + hasActiveShapePreview_ = false; + activeShapePreviewToolType_.clear(); + activeShapePreviewPoints_.clear(); + activeShapePreviewBasePoints_.clear(); + activeShapePreviewPath_.reset(); + hasActiveShapePreviewAnchor_ = false; + activeShapePreviewReferenceAngle_ = 0.0f; + activeShapePreviewReferenceDistance_ = 1.0f; } -void SkiaDrawingEngine::finalizeMove() { +void SkiaDrawingEngine::clear() { std::lock_guard lock(stateMutex_); - if (selectedIndices_.empty()) return; + // Capture the pre-clear state for the delta BEFORE wiping. Clear is + // the one operation that genuinely needs an O(N) snapshot to support + // undo, but it happens once per clear (not per stroke), so the cost + // is bounded. + StrokeDelta delta; + delta.kind = StrokeDelta::Kind::Clear; + delta.clearedStrokes = strokes_; + delta.clearedEraserCircles = eraserCircles_; - // Capture totals before resetting in moveSelection's path-update step - // (which relies on the live offset values). - float totalDx = selectionOffsetX_; - float totalDy = selectionOffsetY_; + strokes_.clear(); + eraserCircles_.clear(); + currentPoints_.clear(); + currentPath_.reset(); + clearActiveShapePreview(); + activeStrokeRenderer_->reset(); // Clear any in-progress incremental rendering + bakedCircleCount_ = 0; // No circles to bake - // Apply accumulated offset to actual point data (ONE time, not per frame) - if (isDraggingSelection_ && (totalDx != 0.0f || totalDy != 0.0f)) { - // Use DrawingSelection to update points and rebuild paths - selection_->moveSelection(strokes_, selectedIndices_, totalDx, totalDy); - } + commitDelta(std::move(delta)); - auto commit = [this](StrokeDelta&& d) { commitDelta(std::move(d)); }; - selection_->finalizeMove(strokes_, selectedIndices_, totalDx, totalDy, commit); - endSelectionDrag(); - // Now rebuild stroke surface with all strokes at final positions needsStrokeRedraw_ = true; + needsEraserMaskRedraw_ = true; } -void SkiaDrawingEngine::beginSelectionTransform(int handleIndex) { +void SkiaDrawingEngine::undo() { std::lock_guard lock(stateMutex_); - if (selectedIndices_.empty()) return; - - endSelectionDrag(); - selectionTransformOriginalStrokes_.clear(); - for (size_t idx : selectedIndices_) { - if (idx < strokes_.size()) { - selectionTransformOriginalStrokes_[idx] = strokes_[idx]; - } - } + if (undoStack_.empty()) return; + StrokeDelta delta = std::move(undoStack_.back()); + undoStack_.pop_back(); + revertDelta(delta); + redoStack_.push_back(std::move(delta)); - isTransformingSelection_ = !selectionTransformOriginalStrokes_.empty(); - selectionTransformHasDelta_ = false; - selectionTransformHandleIndex_ = handleIndex; + cachedEraserCircleCount_ = 0; + bakedCircleCount_ = 0; + clearActiveShapePreview(); + activeStrokeRenderer_->reset(); + needsStrokeRedraw_ = true; + needsEraserMaskRedraw_ = true; } -void SkiaDrawingEngine::updateSelectionTransform(float x, float y) { +void SkiaDrawingEngine::redo() { std::lock_guard lock(stateMutex_); - if (!isTransformingSelection_ || selectionTransformOriginalStrokes_.empty()) { - return; - } - - const SkRect originalBounds = boundsForStrokeCopies(selectionTransformOriginalStrokes_); - if (originalBounds.isEmpty()) { - return; - } - - float anchorX = 0.0f; - float anchorY = 0.0f; - float handleX = 0.0f; - float handleY = 0.0f; - bool affectsX = false; - bool affectsY = false; - getSelectionHandlePoints( - originalBounds, - selectionTransformHandleIndex_, - anchorX, - anchorY, - handleX, - handleY, - affectsX, - affectsY - ); - - auto scaleForAxis = [](float anchor, float originalHandle, float current) { - const float denominator = originalHandle - anchor; - if (std::fabs(denominator) < 0.001f) { - return 1.0f; - } - return std::max(0.05f, std::min(20.0f, (current - anchor) / denominator)); - }; - - const float scaleX = affectsX ? scaleForAxis(anchorX, handleX, x) : 1.0f; - const float scaleY = affectsY ? scaleForAxis(anchorY, handleY, y) : 1.0f; - - for (const auto& [idx, originalStroke] : selectionTransformOriginalStrokes_) { - if (idx >= strokes_.size()) continue; - - Stroke transformed = originalStroke; - for (auto& point : transformed.points) { - point.x = anchorX + (point.x - anchorX) * scaleX; - point.y = anchorY + (point.y - anchorY) * scaleY; - } - for (auto& circle : transformed.erasedBy) { - circle.x = anchorX + (circle.x - anchorX) * scaleX; - circle.y = anchorY + (circle.y - anchorY) * scaleY; - circle.radius *= std::max(0.05f, (std::fabs(scaleX) + std::fabs(scaleY)) * 0.5f); - } - - if (!buildRecognizedShapePath(transformed.toolType, transformed.points, transformed.path)) { - smoothPath(transformed.points, transformed.path); - } - SkPathMeasure pathMeasure(transformed.path, false); - transformed.pathLength = pathMeasure.getLength(); - transformed.cachedEraserCount = 0; - strokes_[idx] = std::move(transformed); - } + if (redoStack_.empty()) return; + StrokeDelta delta = std::move(redoStack_.back()); + redoStack_.pop_back(); + applyDelta(delta); + undoStack_.push_back(std::move(delta)); - selectionTransformHasDelta_ = true; + cachedEraserCircleCount_ = 0; + bakedCircleCount_ = 0; + clearActiveShapePreview(); + activeStrokeRenderer_->reset(); needsStrokeRedraw_ = true; + needsEraserMaskRedraw_ = true; } -void SkiaDrawingEngine::finalizeSelectionTransform() { +void SkiaDrawingEngine::setStrokeColor(SkColor color) { std::lock_guard lock(stateMutex_); - if (!isTransformingSelection_) { - return; - } - - if (selectionTransformHasDelta_) { - StrokeDelta delta; - delta.kind = StrokeDelta::Kind::ReplaceStrokes; - for (const auto& [idx, beforeStroke] : selectionTransformOriginalStrokes_) { - if (idx >= strokes_.size()) continue; - delta.beforeStrokes.push_back({idx, beforeStroke}); - delta.afterStrokes.push_back({idx, strokes_[idx]}); - } - if (!delta.beforeStrokes.empty()) { - commitDelta(std::move(delta)); - } - } - - selectionTransformOriginalStrokes_.clear(); - selectionTransformHandleIndex_ = -1; - selectionTransformHasDelta_ = false; - isTransformingSelection_ = false; - needsStrokeRedraw_ = true; + if (currentTool_ == "eraser") return; + uint8_t a = SkColorGetA(currentPaint_.getColor()); + currentPaint_.setColor(SkColorSetARGB(a, SkColorGetR(color), SkColorGetG(color), SkColorGetB(color))); } -void SkiaDrawingEngine::cancelSelectionTransform() { +void SkiaDrawingEngine::setStrokeWidth(float width) { std::lock_guard lock(stateMutex_); - if (!isTransformingSelection_) { - return; - } - - for (const auto& [idx, beforeStroke] : selectionTransformOriginalStrokes_) { - if (idx < strokes_.size()) { - strokes_[idx] = beforeStroke; - } - } - - selectionTransformOriginalStrokes_.clear(); - selectionTransformHandleIndex_ = -1; - selectionTransformHasDelta_ = false; - isTransformingSelection_ = false; - needsStrokeRedraw_ = true; + currentPaint_.setStrokeWidth(width); } -void SkiaDrawingEngine::prepareSelectionDragCache() { +void SkiaDrawingEngine::setToolWithParams(const char* toolType, float width, uint32_t color, const char* eraserMode) { std::lock_guard lock(stateMutex_); - if (selectedIndices_.empty()) { - hasDragCache_ = false; - return; - } - - SkImageInfo info = SkImageInfo::MakeN32Premul(width_, height_); - - // Cache the background (grid, lines, etc.) - sk_sp bgSurface = SkSurfaces::Raster(info); - if (bgSurface) { - SkCanvas* canvas = bgSurface->getCanvas(); - if (backgroundType_ == "pdf" && pdfBackgroundImage_) { - canvas->clear(SK_ColorWHITE); - backgroundRenderer_->drawBackground( - canvas, - backgroundType_, - width_, - height_, - pdfBackgroundImage_, - backgroundOriginY_ - ); - } else if (backgroundType_ == "pdf") { - canvas->clear(SK_ColorTRANSPARENT); - } else { - canvas->clear(SK_ColorWHITE); - backgroundRenderer_->drawBackground( - canvas, - backgroundType_, - width_, - height_, - pdfBackgroundImage_, - backgroundOriginY_ - ); - } - dragBackgroundSnapshot_ = bgSurface->makeImageSnapshot(); - } - - // Create snapshot of non-selected strokes - sk_sp nonSelectedSurface = SkSurfaces::Raster(info); - if (!nonSelectedSurface) return; - - SkCanvas* canvas = nonSelectedSurface->getCanvas(); - canvas->clear(SK_ColorTRANSPARENT); - - for (size_t i = 0; i < strokes_.size(); ++i) { - if (selectedIndices_.count(i) > 0) continue; - - const auto& stroke = strokes_[i]; - SkPaint strokePaint = stroke.paint; - if (!stroke.isEraser) { - uint8_t baseAlpha = stroke.paint.getAlpha(); - strokePaint.setAlpha(static_cast(baseAlpha * stroke.originalAlphaMod)); - } - - bool needsClipRestore = false; - if (!stroke.erasedBy.empty()) { - stroke.ensureEraserCacheValid(); - if (!stroke.cachedEraserPath.isEmpty()) { - canvas->save(); - canvas->clipPath(stroke.cachedEraserPath, SkClipOp::kDifference); - needsClipRestore = true; - } - } - - renderStrokeGeometry(canvas, stroke, strokePaint); + setTool(toolType); + currentPaint_.setStrokeWidth(width); - if (needsClipRestore) canvas->restore(); + if (toolType && std::string(toolType) != "eraser") { + // Extract RGB from color, but ALWAYS preserve tool's alpha (set by setTool) + uint8_t toolAlpha = SkColorGetA(currentPaint_.getColor()); + uint8_t r = (color >> 16) & 0xFF; + uint8_t g = (color >> 8) & 0xFF; + uint8_t b = color & 0xFF; + currentPaint_.setColor(SkColorSetARGB(toolAlpha, b, g, r)); // Swap R and B for platform } + eraserMode_ = (eraserMode && std::strlen(eraserMode) > 0) ? eraserMode : "pixel"; +} - nonSelectedSnapshot_ = nonSelectedSurface->makeImageSnapshot(); - - // Create snapshot of selected strokes - sk_sp selectedSurface = SkSurfaces::Raster(info); - if (!selectedSurface) return; - - canvas = selectedSurface->getCanvas(); - canvas->clear(SK_ColorTRANSPARENT); - - for (size_t idx : selectedIndices_) { - if (idx >= strokes_.size()) continue; - - const auto& stroke = strokes_[idx]; - SkPaint strokePaint = stroke.paint; - if (!stroke.isEraser) { - uint8_t baseAlpha = stroke.paint.getAlpha(); - strokePaint.setAlpha(static_cast(baseAlpha * stroke.originalAlphaMod)); - } - - bool needsClipRestore = false; - if (!stroke.erasedBy.empty()) { - stroke.ensureEraserCacheValid(); - if (!stroke.cachedEraserPath.isEmpty()) { - canvas->save(); - canvas->clipPath(stroke.cachedEraserPath, SkClipOp::kDifference); - needsClipRestore = true; - } - } - - renderStrokeGeometry(canvas, stroke, strokePaint); +void SkiaDrawingEngine::setTool(const char* toolType) { + std::lock_guard lock(stateMutex_); - if (needsClipRestore) canvas->restore(); - } + currentTool_ = toolType; + std::string tool(toolType); - selectedSnapshot_ = selectedSurface->makeImageSnapshot(); + currentPaint_.setAntiAlias(true); + currentPaint_.setDither(false); - // Cache the selection highlight (dashed outlines) - sk_sp highlightSurface = SkSurfaces::Raster(info); - if (highlightSurface) { - canvas = highlightSurface->getCanvas(); - canvas->clear(SK_ColorTRANSPARENT); - selection_->renderSelection(canvas, strokes_, selectedIndices_); - selectionHighlightSnapshot_ = highlightSurface->makeImageSnapshot(); + if (tool == "pen") { + currentPaint_.setAlpha(255); + currentPaint_.setStrokeCap(SkPaint::kRound_Cap); + currentPaint_.setStrokeJoin(SkPaint::kRound_Join); + currentPaint_.setBlendMode(SkBlendMode::kSrcOver); + } else if (tool == "pencil") { + currentPaint_.setAlpha(200); + currentPaint_.setStrokeCap(SkPaint::kRound_Cap); + currentPaint_.setStrokeJoin(SkPaint::kRound_Join); + currentPaint_.setBlendMode(SkBlendMode::kSrcOver); + } else if (tool == "marker") { + currentPaint_.setAlpha(115); + currentPaint_.setStrokeCap(SkPaint::kRound_Cap); + currentPaint_.setStrokeJoin(SkPaint::kRound_Join); + currentPaint_.setBlendMode(SkBlendMode::kSrcOver); + } else if (tool == "highlighter") { + currentPaint_.setAlpha(140); // Higher base alpha for visibility + currentPaint_.setStrokeCap(SkPaint::kRound_Cap); + currentPaint_.setStrokeJoin(SkPaint::kRound_Join); + currentPaint_.setBlendMode(SkBlendMode::kMultiply); + } else if (tool == "eraser") { + currentPaint_.setAlpha(255); + currentPaint_.setStrokeCap(SkPaint::kRound_Cap); + currentPaint_.setStrokeJoin(SkPaint::kRound_Join); + currentPaint_.setBlendMode(SkBlendMode::kDstOut); + currentPaint_.setColor(SK_ColorBLACK); + } else if (tool == "crayon") { + // Crayon: Semi-transparent (~85%), waxy texture applied during rendering + currentPaint_.setAlpha(217); // ~85% opacity (217/255) + currentPaint_.setStrokeCap(SkPaint::kRound_Cap); + currentPaint_.setStrokeJoin(SkPaint::kRound_Join); + currentPaint_.setBlendMode(SkBlendMode::kSrcOver); + } else if (tool == "calligraphy") { + // Calligraphy: Fully opaque, smooth ink, flex nib behavior + // Width varies based on stroke direction (thin upstrokes, thick downstrokes) + currentPaint_.setAlpha(255); + currentPaint_.setStrokeCap(SkPaint::kRound_Cap); + currentPaint_.setStrokeJoin(SkPaint::kRound_Join); + currentPaint_.setBlendMode(SkBlendMode::kSrcOver); } - - hasDragCache_ = true; } -void SkiaDrawingEngine::beginSelectionDrag() { +std::vector SkiaDrawingEngine::serializeDrawing() { std::lock_guard lock(stateMutex_); - if (selectedIndices_.empty()) return; - - // Pre-cache if not already done - if (!hasDragCache_) { - prepareSelectionDragCache(); - } - - isDraggingSelection_ = true; - selectionOffsetX_ = 0.0f; - selectionOffsetY_ = 0.0f; + return serializer_->serialize(strokes_); } -void SkiaDrawingEngine::endSelectionDrag() { +bool SkiaDrawingEngine::deserializeDrawing(const std::vector& data) { std::lock_guard lock(stateMutex_); + std::vector loadedStrokes; + if (!serializer_->deserialize(data, loadedStrokes)) { + printf("[C++] Failed to deserialize drawing payload safely\n"); + return false; + } + + strokes_ = std::move(loadedStrokes); + eraserCircles_.clear(); // Clear eraser circles when loading + bakedCircleCount_ = 0; // No circles to bake + selectedIndices_.clear(); isDraggingSelection_ = false; hasDragCache_ = false; selectionOffsetX_ = 0.0f; @@ -2935,180 +841,31 @@ void SkiaDrawingEngine::endSelectionDrag() { nonSelectedSnapshot_ = nullptr; selectedSnapshot_ = nullptr; selectionHighlightSnapshot_ = nullptr; + // Reset history. Loading a serialized notebook is treated as a + // checkpoint -- the user wouldn't expect to undo past the load. + undoStack_.clear(); + redoStack_.clear(); + needsStrokeRedraw_ = true; + needsEraserMaskRedraw_ = true; + return true; } -int SkiaDrawingEngine::getSelectionCount() const { +bool SkiaDrawingEngine::canUndo() const { std::lock_guard lock(stateMutex_); - return selection_->getSelectionCount(selectedIndices_); + return !undoStack_.empty(); } -std::vector SkiaDrawingEngine::getSelectionBounds() { +bool SkiaDrawingEngine::canRedo() const { std::lock_guard lock(stateMutex_); - std::vector bounds = selection_->getSelectionBounds(strokes_, selectedIndices_); - if (bounds.size() >= 4 && isDraggingSelection_) { - bounds[0] += selectionOffsetX_; - bounds[1] += selectionOffsetY_; - bounds[2] += selectionOffsetX_; - bounds[3] += selectionOffsetY_; - } - return bounds; -} - -std::vector SkiaDrawingEngine::splitStrokeAtPoint( - const Stroke& originalStroke, - const Point& eraserPoint, - float eraserRadius -) { - return strokeSplitter_->splitStrokeAtPoint(originalStroke, eraserPoint, eraserRadius); -} - -void SkiaDrawingEngine::bakeEraserCircles() { - if (eraserCircles_.size() <= bakedCircleCount_) return; // Nothing new to bake - - // Apply kClear directly to strokeSurface_ using smooth stroke paths - SkCanvas* canvas = strokeSurface_->getCanvas(); - - eraserRenderer_->drawEraserCirclesAsStrokes( - canvas, eraserCircles_, bakedCircleCount_, eraserCircles_.size()); - - // Update cached snapshot - cachedStrokeSnapshot_ = strokeSurface_->makeImageSnapshot(); - - // Mark all circles as baked (but DON'T clear them - needed for redraw/undo) - bakedCircleCount_ = eraserCircles_.size(); - - printf("[C++] bakeEraserCircles: Baked %zu circles total\n", bakedCircleCount_); + return !redoStack_.empty(); } -bool SkiaDrawingEngine::applyPixelEraserAt(float eraserX, float eraserY, float radius) { - // Pixel-perfect eraser: store eraser circles WITH strokes for render-time clipping - // No splitting - curves stay intact, eraser data moves with stroke - bool anyModified = false; - - // Build list of circles to add (may include interpolated circles for full coverage) - std::vector circlesToAdd; - circlesToAdd.push_back({eraserX, eraserY, radius}); - - // Add intermediate circles along path from last position for full coverage - if (hasLastEraserPoint_) { - float dx = eraserX - lastEraserX_; - float dy = eraserY - lastEraserY_; - float dist = std::sqrt(dx * dx + dy * dy); - float avgRadius = (radius + lastEraserRadius_) / 2.0f; - - // Add circles at intervals of radius/2 for overlapping coverage - if (dist > avgRadius * 0.5f) { - int steps = static_cast(dist / (avgRadius * 0.5f)); - for (int s = 1; s < steps; s++) { - float t = static_cast(s) / steps; - float ix = lastEraserX_ + dx * t; - float iy = lastEraserY_ + dy * t; - float ir = lastEraserRadius_ + (radius - lastEraserRadius_) * t; - circlesToAdd.push_back({ix, iy, ir}); - } - } - } - - for (size_t strokeIndex = 0; strokeIndex < strokes_.size(); ++strokeIndex) { - auto& stroke = strokes_[strokeIndex]; - if (stroke.isEraser) continue; - if (stroke.points.size() < 2) continue; - - // Check if eraser path intersects stroke bounds (expand for full path) - SkRect bounds = stroke.path.getBounds(); - bounds.outset(radius, radius); - - bool affectsStroke = false; - for (const auto& circle : circlesToAdd) { - if (bounds.contains(circle.x, circle.y)) { - affectsStroke = true; - break; - } - } - if (!affectsStroke) continue; - - // Store all eraser circles with this stroke for full coverage. - // Each circle is also recorded into pendingPixelEraseEntries_ so - // touchEnded can emit a single PixelErase delta covering the - // whole drag. Without this the per-stroke erasedBy mutations - // would have no record in history -- pixel erase wouldn't undo. - for (const auto& circle : circlesToAdd) { - stroke.erasedBy.push_back(circle); - recordPixelEraseCircleAdded(strokeIndex, circle); - } - - // OPTIMIZATION: Only update visibility if stroke was previously visible - // Skip strokes already marked invisible - they stay invisible - if (stroke.cachedHasVisiblePoints) { - // Quick check: do new circles potentially cover remaining visible points? - // Only do full check every 50 circles to avoid O(n^2) during heavy erasing - size_t circleCount = stroke.erasedBy.size(); - if (circleCount % 50 == 0 || circlesToAdd.size() > 10) { - bool hasVisible = false; - for (const auto& pt : stroke.points) { - bool pointVisible = true; - for (const auto& circle : stroke.erasedBy) { - float dx = pt.x - circle.x; - float dy = pt.y - circle.y; - float totalRadius = circle.radius + pt.calculatedWidth / 2.0f; - if (dx * dx + dy * dy <= totalRadius * totalRadius) { - pointVisible = false; - break; - } - } - if (pointVisible) { - hasVisible = true; - break; - } - } - stroke.cachedHasVisiblePoints = hasVisible; - } - } - - anyModified = true; - } - - if (anyModified) { - // OPTIMIZATION: Apply kClear directly to stroke surface for instant feedback - // This avoids full redraw during active erasing - much faster - if (strokeSurface_) { - SkCanvas* canvas = strokeSurface_->getCanvas(); - SkPaint clearPaint; - clearPaint.setBlendMode(SkBlendMode::kClear); - clearPaint.setAntiAlias(true); - - // Draw stroked path from last position to current for full coverage - // Individual circles leave gaps when moving quickly - if (hasLastEraserPoint_) { - // Draw a stroked line from last position to current - SkPath eraserPath; - eraserPath.moveTo(lastEraserX_, lastEraserY_); - eraserPath.lineTo(eraserX, eraserY); - - clearPaint.setStyle(SkPaint::kStroke_Style); - clearPaint.setStrokeWidth(radius * 2.0f); // Diameter - clearPaint.setStrokeCap(SkPaint::kRound_Cap); - clearPaint.setStrokeJoin(SkPaint::kRound_Join); - canvas->drawPath(eraserPath, clearPaint); - } else { - // First point - just draw circle - canvas->drawCircle(eraserX, eraserY, radius, clearPaint); - } - - cachedStrokeSnapshot_ = strokeSurface_->makeImageSnapshot(); - } - // DON'T set needsStrokeRedraw_ = true - defer full redraw to touchEnded - } - - // Track position for next call - lastEraserX_ = eraserX; - lastEraserY_ = eraserY; - lastEraserRadius_ = radius; - hasLastEraserPoint_ = true; +bool SkiaDrawingEngine::isEmpty() const { + std::lock_guard lock(stateMutex_); - return anyModified; + return strokes_.empty(); } } // namespace nativedrawing diff --git a/cpp/SkiaDrawingEngine.h b/cpp/SkiaDrawingEngine.h index e575736..3b17c92 100644 --- a/cpp/SkiaDrawingEngine.h +++ b/cpp/SkiaDrawingEngine.h @@ -19,6 +19,7 @@ #include #include #include +#include "DrawingTypes.h" namespace nativedrawing { @@ -32,265 +33,6 @@ class StrokeSplitter; class BatchExporter; class ActiveStrokeRenderer; -struct Point { - float x; - float y; - float pressure; - float azimuthAngle; // Pen tilt angle in radians - float altitude; // Pen altitude angle (perpendicular = pi/2) - float calculatedWidth; // Width calculated from pressure + altitude - long timestamp; -}; - -// Pixel eraser circle - stored per-stroke for rendering-time clipping -struct EraserCircle { - float x; - float y; - float radius; -}; - -struct Stroke { - std::vector points; - SkPaint paint; - SkPath path; - bool isEraser = false; // Mark eraser strokes to prevent selection - std::unordered_set affectedStrokeIndices; // For eraser strokes: which strokes they erase - float originalAlphaMod = 1.0f; // Preserve original opacity modifier for consistent appearance - std::string toolType = "pen"; // Tool type for specialized rendering (e.g., crayon needs texture) - float pathLength = 0.0f; // Cached total arc-length of the path - - // Per-stroke eraser data: circles that have erased portions of this stroke - // These move WITH the stroke when selected/moved, ensuring pixel-perfect appearance - // Empty = no erasure, rendering uses full stroke path - std::vector erasedBy; - - // OPTIMIZATION: Cached eraser path for O(1) rendering instead of O(n) path ops per frame - mutable SkPath cachedEraserPath; // Union of all erasedBy circles - mutable size_t cachedEraserCount = 0; // Track when to rebuild cache - - // OPTIMIZATION: Cached visibility for O(1) selection instead of O(m*k) per frame - mutable bool cachedHasVisiblePoints = true; // True if any point is outside all eraser circles - - // Ensure cachedEraserPath is up-to-date with erasedBy circles - // Builds stroked path matching EraserRenderer::drawEraserCirclesAsStrokes - void ensureEraserCacheValid() const { - if (erasedBy.size() != cachedEraserCount) { - // Always rebuild from scratch to match live eraser rendering exactly - cachedEraserPath.reset(); - cachedEraserCount = 0; - - if (erasedBy.empty()) return; - - // Match EraserRenderer::drawEraserCirclesAsStrokes approach: - // Group circles into strokes, create path through centers, stroke with round caps - constexpr float STROKE_BREAK_FACTOR = 2.0f; - size_t strokeStart = 0; - - for (size_t i = 0; i <= erasedBy.size(); ++i) { - bool isLast = (i == erasedBy.size()); - bool breakStroke = isLast; - - if (!isLast && i > strokeStart) { - float dx = erasedBy[i].x - erasedBy[i-1].x; - float dy = erasedBy[i].y - erasedBy[i-1].y; - float dist = std::sqrt(dx * dx + dy * dy); - float avgRadius = (erasedBy[i].radius + erasedBy[i-1].radius) / 2.0f; - if (dist > avgRadius * STROKE_BREAK_FACTOR) { - breakStroke = true; - } - } - - if (breakStroke && i > strokeStart) { - size_t segmentLen = i - strokeStart; - - if (segmentLen == 1) { - // Single point - just add a circle - cachedEraserPath.addCircle(erasedBy[strokeStart].x, - erasedBy[strokeStart].y, - erasedBy[strokeStart].radius); - } else { - // Build path through circle centers - SkPath strokePath; - strokePath.moveTo(erasedBy[strokeStart].x, erasedBy[strokeStart].y); - for (size_t j = strokeStart + 1; j < i; ++j) { - strokePath.lineTo(erasedBy[j].x, erasedBy[j].y); - } - - // Convert stroked path to filled path (matches live eraser exactly) - SkPaint strokePaint; - strokePaint.setStyle(SkPaint::kStroke_Style); - strokePaint.setStrokeWidth(erasedBy[strokeStart].radius * 2.0f); - strokePaint.setStrokeCap(SkPaint::kRound_Cap); - strokePaint.setStrokeJoin(SkPaint::kRound_Join); - - SkPath filledPath; - if (skpathutils::FillPathWithPaint(strokePath, strokePaint, &filledPath)) { - cachedEraserPath.addPath(filledPath); - } else { - // Fallback: add circles for each point - for (size_t j = strokeStart; j < i; ++j) { - cachedEraserPath.addCircle(erasedBy[j].x, - erasedBy[j].y, - erasedBy[j].radius); - } - } - } - - strokeStart = i; - } - } - - cachedEraserCount = erasedBy.size(); - } - } -}; - -// Delta-based history. Each entry describes ONE operation that was -// applied to strokes_, sized in proportion to the operation -- a single -// stroke add is ~1-3 KB, a pixel erase pass over K strokes is ~K * 12 -// bytes per circle, etc. This replaces the previous full-snapshot -// approach where each history entry contained the entire strokes_ -// vector deep-copy: with that model, total history memory grew linearly -// with stroke count even though the entry COUNT was capped, because -// each new snapshot was bigger than the last. On a long drawing -// session this was the dominant cause of the user-reported steady RAM -// climb past 2 GB (47 million live small allocations from duplicated -// stroke point/path vectors across history snapshots). -// -// Undo applies the inverse of the operation in-place; redo applies it -// forward. strokes_ is the canonical state between operations; the -// history is just a sequence of "what happened." -struct StrokeDelta { - enum class Kind : uint8_t { - AddStrokes, // appended at end of strokes_ (touchEnded normal stroke, paste) - RemoveStrokes, // removed at indices (object eraser, delete selection) - PixelErase, // erasedBy circles appended to one or more strokes (pixel eraser) - MoveStrokes, // strokes translated by (dx, dy) (selection finalize-move) - ReplaceStrokes, // selected strokes transformed in place (resize handles) - Clear, // all strokes wiped (clear button, full-screen erase) - }; - Kind kind; - - // For AddStrokes: the strokes that were appended (1 entry for normal - // pen stroke, N entries for paste). Undo = pop_back N, redo = push_back from delta. - std::vector addedStrokes; - - // For RemoveStrokes: pairs of (originalIndex, stroke) in ASCENDING - // index order. Undo re-inserts in ascending order (later inserts see - // valid indices because earlier ones already shifted things into - // place). Redo erases in DESCENDING order so each erase doesn't - // invalidate higher indices. - std::vector> removedStrokes; - - // For PixelErase: one entry per stroke that received eraser circles - // during this op. addedCircles holds the actual circle data so redo - // can re-append them; undo pops addedCircles.size() entries from the - // affected stroke's erasedBy. - struct PixelEraseEntry { - size_t strokeIndex; - std::vector addedCircles; - }; - std::vector pixelEraseEntries; - - // For MoveStrokes: indices of moved strokes + total translation. - // Undo = translate by (-dx, -dy); redo = (dx, dy). We re-run path - // smoothing after the translate either way, so the cached SkPath - // matches. - std::vector moveIndices; - float moveDx = 0.0f; - float moveDy = 0.0f; - - // For ReplaceStrokes: exact before/after snapshots for transformed - // selected strokes. Used by native selection handles where the edit is - // a scale/reshape, not a pure translation. - std::vector> beforeStrokes; - std::vector> afterStrokes; - - // For Clear: the strokes (and any global eraser circles) that - // existed before the clear, so undo can restore them. Bounded to - // exactly one snapshot per clear op, not per stroke. - std::vector clearedStrokes; - std::vector clearedEraserCircles; -}; - -inline bool isRecognizedShapeToolType(const std::string& toolType) { - return toolType == "shape-line" - || toolType == "shape-rectangle" - || toolType == "shape-circle" - || toolType == "shape-ellipse" - || toolType == "shape-polygon"; -} - -inline bool buildRecognizedShapePath( - const std::string& toolType, - const std::vector& points, - SkPath& path -) { - if (!isRecognizedShapeToolType(toolType) || points.size() < 2) { - return false; - } - - path.reset(); - - if (toolType == "shape-line") { - path.moveTo(points.front().x, points.front().y); - path.lineTo(points.back().x, points.back().y); - return true; - } - - float minX = points.front().x; - float maxX = points.front().x; - float minY = points.front().y; - float maxY = points.front().y; - - for (const auto& point : points) { - minX = std::min(minX, point.x); - maxX = std::max(maxX, point.x); - minY = std::min(minY, point.y); - maxY = std::max(maxY, point.y); - } - - if (std::fabs(maxX - minX) < 0.001f || std::fabs(maxY - minY) < 0.001f) { - return false; - } - - const SkRect bounds = SkRect::MakeLTRB(minX, minY, maxX, maxY); - - if (toolType == "shape-polygon" || (toolType == "shape-rectangle" && points.size() >= 4)) { - if (points.size() < 3) { - return false; - } - - path.moveTo(points.front().x, points.front().y); - for (size_t i = 1; i < points.size(); ++i) { - path.lineTo(points[i].x, points[i].y); - } - path.close(); - return true; - } - - if ((toolType == "shape-circle" || toolType == "shape-ellipse") && points.size() >= 6) { - path.moveTo(points.front().x, points.front().y); - for (size_t i = 1; i < points.size(); ++i) { - path.lineTo(points[i].x, points[i].y); - } - path.close(); - return true; - } - - if (toolType == "shape-rectangle") { - path.addRect(bounds); - return true; - } - - if (toolType == "shape-circle" || toolType == "shape-ellipse") { - path.addOval(bounds); - return true; - } - - return false; -} - class SkiaDrawingEngine { public: SkiaDrawingEngine(int width, int height); diff --git a/cpp/SkiaDrawingEngineEraser.cpp b/cpp/SkiaDrawingEngineEraser.cpp new file mode 100644 index 0000000..292b214 --- /dev/null +++ b/cpp/SkiaDrawingEngineEraser.cpp @@ -0,0 +1,238 @@ +#include "SkiaDrawingEngine.h" + +#include "EraserRenderer.h" +#include "StrokeSplitter.h" + +#include +#include +#include + +namespace nativedrawing { + +void SkiaDrawingEngine::eraseObjects() { + std::lock_guard lock(stateMutex_); + + // Helper lambda to remove strokes and update eraser references + auto removeStrokes = [this](const std::unordered_set& indicesToRemove) { + // Capture (idx, stroke) pairs for the removed entries BEFORE + // mutating strokes_. The delta needs them so undo can re-insert + // at the original indices. Sorted ascending. + std::vector sortedIndices(indicesToRemove.begin(), indicesToRemove.end()); + std::sort(sortedIndices.begin(), sortedIndices.end()); + StrokeDelta delta; + delta.kind = StrokeDelta::Kind::RemoveStrokes; + delta.removedStrokes.reserve(sortedIndices.size()); + for (size_t idx : sortedIndices) { + if (idx < strokes_.size()) { + delta.removedStrokes.emplace_back(idx, strokes_[idx]); + } + } + + std::unordered_map oldToNew; + size_t newIdx = 0; + std::vector remaining; + remaining.reserve(strokes_.size() - indicesToRemove.size()); + + for (size_t i = 0; i < strokes_.size(); ++i) { + if (indicesToRemove.count(i) == 0) { + oldToNew[i] = newIdx++; + remaining.push_back(strokes_[i]); + } + } + for (auto& s : remaining) { + if (s.isEraser) { + std::unordered_set updated; + for (size_t old : s.affectedStrokeIndices) + if (oldToNew.count(old)) updated.insert(oldToNew[old]); + s.affectedStrokeIndices = updated; + } + } + if (remaining.size() != strokes_.size()) { + strokes_ = remaining; + commitDelta(std::move(delta)); + needsStrokeRedraw_ = true; + } + }; + + if (!pendingDeleteIndices_.empty()) { + removeStrokes(pendingDeleteIndices_); + pendingDeleteIndices_.clear(); + currentPoints_.clear(); + currentPath_.reset(); + return; + } + + if (currentPoints_.empty()) return; + + // Build eraser bounds from current points + SkRect eraserBounds = SkRect::MakeXYWH(currentPoints_[0].x, currentPoints_[0].y, 0, 0); + for (const auto& pt : currentPoints_) { + eraserBounds.join(SkRect::MakeXYWH(pt.x, pt.y, 0, 0)); + } + eraserBounds.outset(currentPaint_.getStrokeWidth() / 2.0f, currentPaint_.getStrokeWidth() / 2.0f); + + std::unordered_set toRemove; + for (size_t i = 0; i < strokes_.size(); ++i) { + if (strokes_[i].path.getBounds().intersects(eraserBounds)) toRemove.insert(i); + } + removeStrokes(toRemove); + currentPoints_.clear(); + currentPath_.reset(); +} + +std::vector SkiaDrawingEngine::splitStrokeAtPoint( + const Stroke& originalStroke, + const Point& eraserPoint, + float eraserRadius +) { + return strokeSplitter_->splitStrokeAtPoint(originalStroke, eraserPoint, eraserRadius); +} + +void SkiaDrawingEngine::bakeEraserCircles() { + if (eraserCircles_.size() <= bakedCircleCount_) return; // Nothing new to bake + + // Apply kClear directly to strokeSurface_ using smooth stroke paths + SkCanvas* canvas = strokeSurface_->getCanvas(); + + eraserRenderer_->drawEraserCirclesAsStrokes( + canvas, eraserCircles_, bakedCircleCount_, eraserCircles_.size()); + + // Update cached snapshot + cachedStrokeSnapshot_ = strokeSurface_->makeImageSnapshot(); + + // Mark all circles as baked (but DON'T clear them - needed for redraw/undo) + bakedCircleCount_ = eraserCircles_.size(); + + printf("[C++] bakeEraserCircles: Baked %zu circles total\n", bakedCircleCount_); +} + +bool SkiaDrawingEngine::applyPixelEraserAt(float eraserX, float eraserY, float radius) { + // Pixel-perfect eraser: store eraser circles WITH strokes for render-time clipping + // No splitting - curves stay intact, eraser data moves with stroke + bool anyModified = false; + + // Build list of circles to add (may include interpolated circles for full coverage) + std::vector circlesToAdd; + circlesToAdd.push_back({eraserX, eraserY, radius}); + + // Add intermediate circles along path from last position for full coverage + if (hasLastEraserPoint_) { + float dx = eraserX - lastEraserX_; + float dy = eraserY - lastEraserY_; + float dist = std::sqrt(dx * dx + dy * dy); + float avgRadius = (radius + lastEraserRadius_) / 2.0f; + + // Add circles at intervals of radius/2 for overlapping coverage + if (dist > avgRadius * 0.5f) { + int steps = static_cast(dist / (avgRadius * 0.5f)); + for (int s = 1; s < steps; s++) { + float t = static_cast(s) / steps; + float ix = lastEraserX_ + dx * t; + float iy = lastEraserY_ + dy * t; + float ir = lastEraserRadius_ + (radius - lastEraserRadius_) * t; + circlesToAdd.push_back({ix, iy, ir}); + } + } + } + + for (size_t strokeIndex = 0; strokeIndex < strokes_.size(); ++strokeIndex) { + auto& stroke = strokes_[strokeIndex]; + if (stroke.isEraser) continue; + if (stroke.points.size() < 2) continue; + + // Check if eraser path intersects stroke bounds (expand for full path) + SkRect bounds = stroke.path.getBounds(); + bounds.outset(radius, radius); + + bool affectsStroke = false; + for (const auto& circle : circlesToAdd) { + if (bounds.contains(circle.x, circle.y)) { + affectsStroke = true; + break; + } + } + if (!affectsStroke) continue; + + // Store all eraser circles with this stroke for full coverage. + // Each circle is also recorded into pendingPixelEraseEntries_ so + // touchEnded can emit a single PixelErase delta covering the + // whole drag. Without this the per-stroke erasedBy mutations + // would have no record in history -- pixel erase wouldn't undo. + for (const auto& circle : circlesToAdd) { + stroke.erasedBy.push_back(circle); + recordPixelEraseCircleAdded(strokeIndex, circle); + } + + // OPTIMIZATION: Only update visibility if stroke was previously visible + // Skip strokes already marked invisible - they stay invisible + if (stroke.cachedHasVisiblePoints) { + // Quick check: do new circles potentially cover remaining visible points? + // Only do full check every 50 circles to avoid O(n^2) during heavy erasing + size_t circleCount = stroke.erasedBy.size(); + if (circleCount % 50 == 0 || circlesToAdd.size() > 10) { + bool hasVisible = false; + for (const auto& pt : stroke.points) { + bool pointVisible = true; + for (const auto& circle : stroke.erasedBy) { + float dx = pt.x - circle.x; + float dy = pt.y - circle.y; + float totalRadius = circle.radius + pt.calculatedWidth / 2.0f; + if (dx * dx + dy * dy <= totalRadius * totalRadius) { + pointVisible = false; + break; + } + } + if (pointVisible) { + hasVisible = true; + break; + } + } + stroke.cachedHasVisiblePoints = hasVisible; + } + } + + anyModified = true; + } + + if (anyModified) { + // OPTIMIZATION: Apply kClear directly to stroke surface for instant feedback + // This avoids full redraw during active erasing - much faster + if (strokeSurface_) { + SkCanvas* canvas = strokeSurface_->getCanvas(); + SkPaint clearPaint; + clearPaint.setBlendMode(SkBlendMode::kClear); + clearPaint.setAntiAlias(true); + + // Draw stroked path from last position to current for full coverage + // Individual circles leave gaps when moving quickly + if (hasLastEraserPoint_) { + // Draw a stroked line from last position to current + SkPath eraserPath; + eraserPath.moveTo(lastEraserX_, lastEraserY_); + eraserPath.lineTo(eraserX, eraserY); + + clearPaint.setStyle(SkPaint::kStroke_Style); + clearPaint.setStrokeWidth(radius * 2.0f); // Diameter + clearPaint.setStrokeCap(SkPaint::kRound_Cap); + clearPaint.setStrokeJoin(SkPaint::kRound_Join); + canvas->drawPath(eraserPath, clearPaint); + } else { + // First point - just draw circle + canvas->drawCircle(eraserX, eraserY, radius, clearPaint); + } + + cachedStrokeSnapshot_ = strokeSurface_->makeImageSnapshot(); + } + // DON'T set needsStrokeRedraw_ = true - defer full redraw to touchEnded + } + + // Track position for next call + lastEraserX_ = eraserX; + lastEraserY_ = eraserY; + lastEraserRadius_ = radius; + hasLastEraserPoint_ = true; + + return anyModified; +} + +} // namespace nativedrawing diff --git a/cpp/SkiaDrawingEngineRendering.cpp b/cpp/SkiaDrawingEngineRendering.cpp new file mode 100644 index 0000000..76cbdeb --- /dev/null +++ b/cpp/SkiaDrawingEngineRendering.cpp @@ -0,0 +1,454 @@ +#include "SkiaDrawingEngine.h" + +#include "ActiveStrokeRenderer.h" +#include "BackgroundRenderer.h" +#include "BatchExporter.h" +#include "DrawingSelection.h" +#include "PathRenderer.h" +#include "ShapeRecognition.h" + +#include +#include +#include + +#include +#include +#include + +namespace nativedrawing { + +namespace { + +SkColor swapRedBlueChannels(SkColor color) { + return SkColorSetARGB( + SkColorGetA(color), + SkColorGetB(color), + SkColorGetG(color), + SkColorGetR(color) + ); +} + +void normalizeStrokeColorsForRasterExport(std::vector& strokes) { + for (auto& stroke : strokes) { + if (stroke.isEraser) { + continue; + } + + stroke.paint.setColor(swapRedBlueChannels(stroke.paint.getColor())); + } +} + +} // namespace + +void SkiaDrawingEngine::setBackgroundType(const char* backgroundType) { + std::lock_guard lock(stateMutex_); + + backgroundType_ = backgroundType ? backgroundType : "plain"; +} + +std::string SkiaDrawingEngine::getBackgroundType() const { + std::lock_guard lock(stateMutex_); + return backgroundType_; +} + +void SkiaDrawingEngine::setPdfBackgroundImage(sk_sp image) { + std::lock_guard lock(stateMutex_); + + pdfBackgroundImage_ = image; +} + +void SkiaDrawingEngine::renderStrokeGeometry(SkCanvas* canvas, const Stroke& stroke, const SkPaint& paint) { + if (!canvas) { + return; + } + + if (isRecognizedShapeToolType(stroke.toolType)) { + SkPath shapePath = stroke.path; + if (shapePath.isEmpty()) { + buildRecognizedShapePath(stroke.toolType, stroke.points, shapePath); + } + + if (shapePath.isEmpty()) { + return; + } + + SkPaint shapePaint = paint; + shapePaint.setStyle(SkPaint::kStroke_Style); + shapePaint.setStrokeWidth(averageCalculatedWidth(stroke.points)); + shapePaint.setStrokeJoin(stroke.toolType == "shape-line" + ? SkPaint::kRound_Join + : SkPaint::kMiter_Join); + shapePaint.setStrokeCap(stroke.toolType == "shape-line" + ? SkPaint::kRound_Cap + : SkPaint::kButt_Cap); + canvas->drawPath(shapePath, shapePaint); + return; + } + + if (stroke.toolType == "crayon") { + pathRenderer_->drawCrayonPath(canvas, stroke.points, paint, false); + } else if (stroke.toolType == "calligraphy") { + pathRenderer_->drawCalligraphyPath(canvas, stroke.points, paint, false); + } else { + pathRenderer_->drawVariableWidthPath(canvas, stroke.points, paint, false); + } +} + +void SkiaDrawingEngine::redrawStrokes() { + if (!needsStrokeRedraw_) return; + + SkCanvas* canvas = strokeSurface_->getCanvas(); + canvas->clear(SK_ColorTRANSPARENT); + + // Helper to render a single stroke with per-stroke eraser clipping + auto renderStroke = [&](size_t i) { + const auto& stroke = strokes_[i]; + SkPaint strokePaint = stroke.paint; + + if (!stroke.isEraser) { + uint8_t baseAlpha = stroke.paint.getAlpha(); + strokePaint.setAlpha(static_cast(baseAlpha * stroke.originalAlphaMod)); + } + + if (pendingDeleteIndices_.count(i) > 0) { + strokePaint.setAlpha(strokePaint.getAlpha() * 0.3); + } + + // Apply per-stroke eraser clipping if this stroke has been erased + bool needsClipRestore = false; + if (!stroke.erasedBy.empty()) { + // Ensure cache is up-to-date (builds smooth capsule shapes between circles) + stroke.ensureEraserCacheValid(); + if (!stroke.cachedEraserPath.isEmpty()) { + // Clip out the erased regions (kDifference = draw everywhere EXCEPT cached path) + canvas->save(); + canvas->clipPath(stroke.cachedEraserPath, SkClipOp::kDifference); + needsClipRestore = true; + } + } + + // Render stroke (curves unchanged - clipping handles pixel-perfect erasure) + renderStrokeGeometry(canvas, stroke, strokePaint); + + if (needsClipRestore) { + canvas->restore(); + } + }; + + // Render all strokes - eraser effect applied via per-stroke clipping + for (size_t strokeIdx = 0; strokeIdx < strokes_.size(); ++strokeIdx) { + renderStroke(strokeIdx); + } + + // All strokes are now in strokeSurface_ + maxAffectedStrokeIndex_ = strokes_.size(); + + // Cache snapshot for fast rendering (avoid makeImageSnapshot every frame) + cachedStrokeSnapshot_ = strokeSurface_->makeImageSnapshot(); + + needsStrokeRedraw_ = false; +} + +void SkiaDrawingEngine::redrawEraserMask() { + if (!needsEraserMaskRedraw_) return; + + SkCanvas* canvas = eraserMaskSurface_->getCanvas(); + canvas->clear(SK_ColorWHITE); // Full alpha (255) = visible + + if (!eraserCircles_.empty()) { + SkPaint erasePaint; + erasePaint.setBlendMode(SkBlendMode::kClear); // Sets pixels to 0 alpha (transparent = erased) + erasePaint.setAntiAlias(true); + erasePaint.setStyle(SkPaint::kFill_Style); + + // Build path from all circles (or use cached if available) + if (eraserCircles_.size() != cachedEraserCircleCount_) { + cachedEraserPath_.reset(); + for (const auto& circle : eraserCircles_) { + cachedEraserPath_.addCircle(circle.x, circle.y, circle.radius); + } + cachedEraserCircleCount_ = eraserCircles_.size(); + } + + canvas->drawPath(cachedEraserPath_, erasePaint); + } + + needsEraserMaskRedraw_ = false; +} + +void SkiaDrawingEngine::render(SkCanvas* canvas) { + std::lock_guard lock(stateMutex_); + + // OPTIMIZATION: When dragging selection, use all cached snapshots - pure O(1) per frame + if (!selectedIndices_.empty() && isDraggingSelection_) { + // Draw cached background - O(1) + if (dragBackgroundSnapshot_) { + canvas->drawImage(dragBackgroundSnapshot_, 0, 0); + } + + // Draw cached non-selected strokes - O(1) + if (nonSelectedSnapshot_) { + canvas->drawImage(nonSelectedSnapshot_, 0, 0); + } + + // Draw cached selected strokes with offset - O(1) + if (selectedSnapshot_) { + canvas->drawImage(selectedSnapshot_, selectionOffsetX_, selectionOffsetY_); + } + } else { + // Normal path: draw background + if (backgroundType_ == "pdf") { + if (pdfBackgroundImage_) { + canvas->clear(SK_ColorWHITE); + backgroundRenderer_->drawBackground( + canvas, + backgroundType_, + width_, + height_, + pdfBackgroundImage_, + backgroundOriginY_ + ); + } else { + canvas->clear(SK_ColorTRANSPARENT); + } + } else { + canvas->clear(SK_ColorWHITE); + backgroundRenderer_->drawBackground( + canvas, + backgroundType_, + width_, + height_, + pdfBackgroundImage_, + backgroundOriginY_ + ); + } + + // Draw strokes + if (!selectedIndices_.empty() && !needsStrokeRedraw_) { + // Selection exists but not actively dragging - render all strokes directly + for (size_t i = 0; i < strokes_.size(); ++i) { + const auto& stroke = strokes_[i]; + SkPaint strokePaint = stroke.paint; + + if (!stroke.isEraser) { + uint8_t baseAlpha = stroke.paint.getAlpha(); + strokePaint.setAlpha(static_cast(baseAlpha * stroke.originalAlphaMod)); + } + + bool needsClipRestore = false; + if (!stroke.erasedBy.empty()) { + stroke.ensureEraserCacheValid(); + if (!stroke.cachedEraserPath.isEmpty()) { + canvas->save(); + canvas->clipPath(stroke.cachedEraserPath, SkClipOp::kDifference); + needsClipRestore = true; + } + } + + renderStrokeGeometry(canvas, stroke, strokePaint); + + if (needsClipRestore) { + canvas->restore(); + } + } + } else { + // Normal path: use cached surface + redrawStrokes(); + + // OPTIMIZATION: If object eraser is active, clip out pending-delete strokes + if (!pendingDeleteIndices_.empty() && cachedStrokeSnapshot_) { + SkPath excludePath; + for (size_t idx : pendingDeleteIndices_) { + if (idx >= strokes_.size()) continue; + SkRect bounds = strokes_[idx].path.getBounds(); + bounds.outset(strokes_[idx].paint.getStrokeWidth(), strokes_[idx].paint.getStrokeWidth()); + excludePath.addRect(bounds); + } + + canvas->save(); + canvas->clipPath(excludePath, SkClipOp::kDifference); + canvas->drawImage(cachedStrokeSnapshot_, 0, 0); + canvas->restore(); + + for (size_t idx : pendingDeleteIndices_) { + if (idx >= strokes_.size()) continue; + const auto& stroke = strokes_[idx]; + + SkPaint dimPaint = stroke.paint; + uint8_t baseAlpha = stroke.paint.getAlpha(); + dimPaint.setAlpha(static_cast(baseAlpha * stroke.originalAlphaMod * 0.3f)); + + bool needsClipRestore = false; + if (!stroke.erasedBy.empty()) { + stroke.ensureEraserCacheValid(); + if (!stroke.cachedEraserPath.isEmpty()) { + canvas->save(); + canvas->clipPath(stroke.cachedEraserPath, SkClipOp::kDifference); + needsClipRestore = true; + } + } + + renderStrokeGeometry(canvas, stroke, dimPaint); + + if (needsClipRestore) { + canvas->restore(); + } + } + } else if (cachedStrokeSnapshot_) { + canvas->drawImage(cachedStrokeSnapshot_, 0, 0); + } + } + } + + // 4. Draw active stroke incrementally (O(1) per frame instead of O(n)) + if (hasActiveShapePreview_ && !activeShapePreviewPoints_.empty()) { + Stroke previewStroke; + previewStroke.points = activeShapePreviewPoints_; + previewStroke.paint = currentPaint_; + previewStroke.path = activeShapePreviewPath_; + previewStroke.toolType = activeShapePreviewToolType_; + + SkPaint previewPaint = currentPaint_; + if (currentTool_ != "highlighter" && currentTool_ != "marker") { + const float pressureAlphaMod = 0.85f + (averagePressure(currentPoints_) * 0.15f); + previewPaint.setAlpha(static_cast(previewPaint.getAlpha() * pressureAlphaMod)); + } + + renderStrokeGeometry(canvas, previewStroke, previewPaint); + } else if (currentPoints_.size() >= 2 && currentTool_ != "select" && currentTool_ != "eraser") { + activeStrokeRenderer_->renderIncremental(canvas, currentPoints_, currentPaint_, currentTool_); + } + + // Draw eraser cursor for pixel eraser + if (showEraserCursor_ && eraserCursorRadius_ > 0) { + SkPaint cursorPaint; + cursorPaint.setStyle(SkPaint::kStroke_Style); + cursorPaint.setColor(SkColorSetARGB(180, 128, 128, 128)); + cursorPaint.setStrokeWidth(2.0f); + cursorPaint.setAntiAlias(true); + + canvas->drawCircle(eraserCursorX_, eraserCursorY_, eraserCursorRadius_, cursorPaint); + } + + // Draw lasso path if active (during selection drag) + if (currentTool_ == "select") { + selection_->renderLasso(canvas); + } + + // Draw selection highlight if strokes are selected + if (isDraggingSelection_ && selectionHighlightSnapshot_) { + // During drag, use cached highlight with offset - O(1) + canvas->drawImage(selectionHighlightSnapshot_, selectionOffsetX_, selectionOffsetY_); + } else { + selection_->renderSelection(canvas, strokes_, selectedIndices_); + } +} + +sk_sp SkiaDrawingEngine::makeSnapshot() { + std::lock_guard lock(stateMutex_); + + SkImageInfo info = SkImageInfo::MakeN32Premul(width_, height_); + sk_sp surface = SkSurfaces::Raster(info); + render(surface->getCanvas()); + return surface->makeImageSnapshot(); +} + +std::vector SkiaDrawingEngine::batchExportPages( + const std::vector>& pagesData, + const std::vector& backgroundTypes, + const std::vector>& pdfBackgrounds, + const std::vector& pageIndices, + float scale +) { + std::lock_guard lock(stateMutex_); + + std::vector results; + results.reserve(pagesData.size()); + + int scaledWidth = static_cast(width_ * scale); + int scaledHeight = static_cast(height_ * scale); + SkImageInfo info = SkImageInfo::MakeN32Premul(scaledWidth, scaledHeight); + sk_sp exportSurface = SkSurfaces::Raster(info); + + if (!exportSurface) { + printf("[C++] batchExportPages: Failed to create export surface\n"); + return results; + } + + // Save original state + auto originalStrokes = strokes_; + auto originalEraserCircles = eraserCircles_; + auto originalPdfBackground = pdfBackgroundImage_; + auto originalBackgroundType = backgroundType_; + float originalBackgroundOriginY = backgroundOriginY_; + auto originalUndoStack = undoStack_; + auto originalRedoStack = redoStack_; + + for (size_t i = 0; i < pagesData.size(); ++i) { + SkCanvas* canvas = exportSurface->getCanvas(); + canvas->clear(SK_ColorTRANSPARENT); + canvas->save(); + canvas->scale(scale, scale); + + backgroundType_ = (i < backgroundTypes.size() && !backgroundTypes[i].empty()) + ? backgroundTypes[i] : "plain"; + pdfBackgroundImage_ = (i < pdfBackgrounds.size()) ? pdfBackgrounds[i] : nullptr; + int pageIndex = (i < pageIndices.size()) ? pageIndices[i] : static_cast(i); + backgroundOriginY_ = std::max(0, pageIndex) * static_cast(height_); + + if (!pagesData[i].empty()) { + if (!deserializeDrawing(pagesData[i])) { + strokes_.clear(); + eraserCircles_.clear(); + } + } else { + strokes_.clear(); + eraserCircles_.clear(); + } + + normalizeStrokeColorsForRasterExport(strokes_); + + needsStrokeRedraw_ = true; + needsEraserMaskRedraw_ = true; + render(canvas); + canvas->restore(); + + sk_sp snapshot = exportSurface->makeImageSnapshot(); + if (snapshot) { + sk_sp pngData = SkPngEncoder::Encode(nullptr, snapshot.get(), {}); + if (pngData) { + results.push_back("data:image/png;base64," + + BatchExporter::encodeBase64(pngData->data(), pngData->size())); + } else { + results.push_back(""); + } + } else { + results.push_back(""); + } + } + + // Restore original state + strokes_ = originalStrokes; + eraserCircles_ = originalEraserCircles; + cachedEraserCircleCount_ = 0; + pdfBackgroundImage_ = originalPdfBackground; + backgroundType_ = originalBackgroundType; + backgroundOriginY_ = originalBackgroundOriginY; + undoStack_ = std::move(originalUndoStack); + redoStack_ = std::move(originalRedoStack); + needsStrokeRedraw_ = true; + needsEraserMaskRedraw_ = true; + + return results; +} + +void SkiaDrawingEngine::setEraserCursor(float x, float y, float radius, bool visible) { + std::lock_guard lock(stateMutex_); + + eraserCursorX_ = x; + eraserCursorY_ = y; + eraserCursorRadius_ = radius; + showEraserCursor_ = visible; +} + +} // namespace nativedrawing diff --git a/cpp/SkiaDrawingEngineSelection.cpp b/cpp/SkiaDrawingEngineSelection.cpp new file mode 100644 index 0000000..dfa2a49 --- /dev/null +++ b/cpp/SkiaDrawingEngineSelection.cpp @@ -0,0 +1,506 @@ +#include "SkiaDrawingEngine.h" + +#include "BackgroundRenderer.h" +#include "DrawingSelection.h" +#include "ShapeRecognition.h" + +#include +#include +#include +#include + +#include + +namespace nativedrawing { + +namespace { + +SkRect boundsForStrokeCopies(const std::unordered_map& strokes) { + bool hasPoint = false; + float minX = std::numeric_limits::max(); + float minY = std::numeric_limits::max(); + float maxX = std::numeric_limits::lowest(); + float maxY = std::numeric_limits::lowest(); + + for (const auto& entry : strokes) { + for (const auto& point : entry.second.points) { + hasPoint = true; + minX = std::min(minX, point.x); + minY = std::min(minY, point.y); + maxX = std::max(maxX, point.x); + maxY = std::max(maxY, point.y); + } + } + + if (!hasPoint) { + return SkRect::MakeEmpty(); + } + + if (std::fabs(maxX - minX) < 1.0f) { + minX -= 0.5f; + maxX += 0.5f; + } + if (std::fabs(maxY - minY) < 1.0f) { + minY -= 0.5f; + maxY += 0.5f; + } + + return SkRect::MakeLTRB(minX, minY, maxX, maxY); +} + +void getSelectionHandlePoints( + const SkRect& bounds, + int handleIndex, + float& anchorX, + float& anchorY, + float& handleX, + float& handleY, + bool& affectsX, + bool& affectsY +) { + const float centerX = (bounds.fLeft + bounds.fRight) * 0.5f; + const float centerY = (bounds.fTop + bounds.fBottom) * 0.5f; + + switch (handleIndex) { + case 0: + anchorX = bounds.fRight; anchorY = bounds.fBottom; + handleX = bounds.fLeft; handleY = bounds.fTop; + affectsX = true; affectsY = true; + break; + case 1: + anchorX = centerX; anchorY = bounds.fBottom; + handleX = centerX; handleY = bounds.fTop; + affectsX = false; affectsY = true; + break; + case 2: + anchorX = bounds.fLeft; anchorY = bounds.fBottom; + handleX = bounds.fRight; handleY = bounds.fTop; + affectsX = true; affectsY = true; + break; + case 3: + anchorX = bounds.fRight; anchorY = centerY; + handleX = bounds.fLeft; handleY = centerY; + affectsX = true; affectsY = false; + break; + case 4: + anchorX = bounds.fLeft; anchorY = centerY; + handleX = bounds.fRight; handleY = centerY; + affectsX = true; affectsY = false; + break; + case 5: + anchorX = bounds.fRight; anchorY = bounds.fTop; + handleX = bounds.fLeft; handleY = bounds.fBottom; + affectsX = true; affectsY = true; + break; + case 6: + anchorX = centerX; anchorY = bounds.fTop; + handleX = centerX; handleY = bounds.fBottom; + affectsX = false; affectsY = true; + break; + case 7: + default: + anchorX = bounds.fLeft; anchorY = bounds.fTop; + handleX = bounds.fRight; handleY = bounds.fBottom; + affectsX = true; affectsY = true; + break; + } +} + +} // namespace + +// Selection operations - delegate to selection module +bool SkiaDrawingEngine::selectStrokeAt(float x, float y) { + std::lock_guard lock(stateMutex_); + + // Selection highlight is drawn directly to output canvas, no stroke redraw needed + bool changed = selection_->selectStrokeAt(x, y, strokes_, selectedIndices_); + if (changed) { + // Pre-cache for smooth drag start + prepareSelectionDragCache(); + } + return changed; +} + +bool SkiaDrawingEngine::selectShapeStrokeAt(float x, float y) { + std::lock_guard lock(stateMutex_); + + // Finger tap selection is object-like: only recognized, snapped shape + // strokes should behave as tappable objects. Freehand handwriting remains + // selectable through the explicit select/lasso tool. + bool changed = selection_->selectShapeStrokeAt(x, y, strokes_, selectedIndices_); + if (changed) { + prepareSelectionDragCache(); + } + return changed; +} + +void SkiaDrawingEngine::clearSelection() { + std::lock_guard lock(stateMutex_); + + // Selection highlight is drawn directly to output canvas, no stroke redraw needed + if (!selectedIndices_.empty()) { + selection_->clearSelection(selectedIndices_); + endSelectionDrag(); // Free cached snapshots to prevent memory leak + } +} + +void SkiaDrawingEngine::deleteSelection() { + std::lock_guard lock(stateMutex_); + + auto commit = [this](StrokeDelta&& d) { commitDelta(std::move(d)); }; + selection_->deleteSelection(strokes_, selectedIndices_, commit); + needsStrokeRedraw_ = true; +} + +void SkiaDrawingEngine::copySelection() { + std::lock_guard lock(stateMutex_); + + selection_->copySelection(strokes_, selectedIndices_, copiedStrokes_); +} + +void SkiaDrawingEngine::pasteSelection(float offsetX, float offsetY) { + std::lock_guard lock(stateMutex_); + + auto commit = [this](StrokeDelta&& d) { commitDelta(std::move(d)); }; + selection_->pasteSelection(strokes_, copiedStrokes_, offsetX, offsetY, commit); + needsStrokeRedraw_ = true; +} + +void SkiaDrawingEngine::moveSelection(float dx, float dy) { + std::lock_guard lock(stateMutex_); + + if (selectedIndices_.empty()) return; + + if (!isDraggingSelection_) { + beginSelectionDrag(); + } + + // OPTIMIZATION: Just accumulate offset - O(1), no path/point modification + // Selected strokes are rendered from cached snapshot with offset + selectionOffsetX_ += dx; + selectionOffsetY_ += dy; +} + +void SkiaDrawingEngine::finalizeMove() { + std::lock_guard lock(stateMutex_); + + if (selectedIndices_.empty()) return; + + // Capture totals before resetting in moveSelection's path-update step + // (which relies on the live offset values). + float totalDx = selectionOffsetX_; + float totalDy = selectionOffsetY_; + + // Apply accumulated offset to actual point data (ONE time, not per frame) + if (isDraggingSelection_ && (totalDx != 0.0f || totalDy != 0.0f)) { + // Use DrawingSelection to update points and rebuild paths + selection_->moveSelection(strokes_, selectedIndices_, totalDx, totalDy); + } + + auto commit = [this](StrokeDelta&& d) { commitDelta(std::move(d)); }; + selection_->finalizeMove(strokes_, selectedIndices_, totalDx, totalDy, commit); + endSelectionDrag(); + // Now rebuild stroke surface with all strokes at final positions + needsStrokeRedraw_ = true; +} + +void SkiaDrawingEngine::beginSelectionTransform(int handleIndex) { + std::lock_guard lock(stateMutex_); + + if (selectedIndices_.empty()) return; + + endSelectionDrag(); + selectionTransformOriginalStrokes_.clear(); + for (size_t idx : selectedIndices_) { + if (idx < strokes_.size()) { + selectionTransformOriginalStrokes_[idx] = strokes_[idx]; + } + } + + isTransformingSelection_ = !selectionTransformOriginalStrokes_.empty(); + selectionTransformHasDelta_ = false; + selectionTransformHandleIndex_ = handleIndex; +} + +void SkiaDrawingEngine::updateSelectionTransform(float x, float y) { + std::lock_guard lock(stateMutex_); + + if (!isTransformingSelection_ || selectionTransformOriginalStrokes_.empty()) { + return; + } + + const SkRect originalBounds = boundsForStrokeCopies(selectionTransformOriginalStrokes_); + if (originalBounds.isEmpty()) { + return; + } + + float anchorX = 0.0f; + float anchorY = 0.0f; + float handleX = 0.0f; + float handleY = 0.0f; + bool affectsX = false; + bool affectsY = false; + getSelectionHandlePoints( + originalBounds, + selectionTransformHandleIndex_, + anchorX, + anchorY, + handleX, + handleY, + affectsX, + affectsY + ); + + auto scaleForAxis = [](float anchor, float originalHandle, float current) { + const float denominator = originalHandle - anchor; + if (std::fabs(denominator) < 0.001f) { + return 1.0f; + } + return std::max(0.05f, std::min(20.0f, (current - anchor) / denominator)); + }; + + const float scaleX = affectsX ? scaleForAxis(anchorX, handleX, x) : 1.0f; + const float scaleY = affectsY ? scaleForAxis(anchorY, handleY, y) : 1.0f; + + for (const auto& [idx, originalStroke] : selectionTransformOriginalStrokes_) { + if (idx >= strokes_.size()) continue; + + Stroke transformed = originalStroke; + for (auto& point : transformed.points) { + point.x = anchorX + (point.x - anchorX) * scaleX; + point.y = anchorY + (point.y - anchorY) * scaleY; + } + for (auto& circle : transformed.erasedBy) { + circle.x = anchorX + (circle.x - anchorX) * scaleX; + circle.y = anchorY + (circle.y - anchorY) * scaleY; + circle.radius *= std::max(0.05f, (std::fabs(scaleX) + std::fabs(scaleY)) * 0.5f); + } + + if (!buildRecognizedShapePath(transformed.toolType, transformed.points, transformed.path)) { + smoothPath(transformed.points, transformed.path); + } + SkPathMeasure pathMeasure(transformed.path, false); + transformed.pathLength = pathMeasure.getLength(); + transformed.cachedEraserCount = 0; + strokes_[idx] = std::move(transformed); + } + + selectionTransformHasDelta_ = true; + needsStrokeRedraw_ = true; +} + +void SkiaDrawingEngine::finalizeSelectionTransform() { + std::lock_guard lock(stateMutex_); + + if (!isTransformingSelection_) { + return; + } + + if (selectionTransformHasDelta_) { + StrokeDelta delta; + delta.kind = StrokeDelta::Kind::ReplaceStrokes; + for (const auto& [idx, beforeStroke] : selectionTransformOriginalStrokes_) { + if (idx >= strokes_.size()) continue; + delta.beforeStrokes.push_back({idx, beforeStroke}); + delta.afterStrokes.push_back({idx, strokes_[idx]}); + } + if (!delta.beforeStrokes.empty()) { + commitDelta(std::move(delta)); + } + } + + selectionTransformOriginalStrokes_.clear(); + selectionTransformHandleIndex_ = -1; + selectionTransformHasDelta_ = false; + isTransformingSelection_ = false; + needsStrokeRedraw_ = true; +} + +void SkiaDrawingEngine::cancelSelectionTransform() { + std::lock_guard lock(stateMutex_); + + if (!isTransformingSelection_) { + return; + } + + for (const auto& [idx, beforeStroke] : selectionTransformOriginalStrokes_) { + if (idx < strokes_.size()) { + strokes_[idx] = beforeStroke; + } + } + + selectionTransformOriginalStrokes_.clear(); + selectionTransformHandleIndex_ = -1; + selectionTransformHasDelta_ = false; + isTransformingSelection_ = false; + needsStrokeRedraw_ = true; +} + +void SkiaDrawingEngine::prepareSelectionDragCache() { + std::lock_guard lock(stateMutex_); + + if (selectedIndices_.empty()) { + hasDragCache_ = false; + return; + } + + SkImageInfo info = SkImageInfo::MakeN32Premul(width_, height_); + + // Cache the background (grid, lines, etc.) + sk_sp bgSurface = SkSurfaces::Raster(info); + if (bgSurface) { + SkCanvas* canvas = bgSurface->getCanvas(); + if (backgroundType_ == "pdf" && pdfBackgroundImage_) { + canvas->clear(SK_ColorWHITE); + backgroundRenderer_->drawBackground( + canvas, + backgroundType_, + width_, + height_, + pdfBackgroundImage_, + backgroundOriginY_ + ); + } else if (backgroundType_ == "pdf") { + canvas->clear(SK_ColorTRANSPARENT); + } else { + canvas->clear(SK_ColorWHITE); + backgroundRenderer_->drawBackground( + canvas, + backgroundType_, + width_, + height_, + pdfBackgroundImage_, + backgroundOriginY_ + ); + } + dragBackgroundSnapshot_ = bgSurface->makeImageSnapshot(); + } + + // Create snapshot of non-selected strokes + sk_sp nonSelectedSurface = SkSurfaces::Raster(info); + if (!nonSelectedSurface) return; + + SkCanvas* canvas = nonSelectedSurface->getCanvas(); + canvas->clear(SK_ColorTRANSPARENT); + + for (size_t i = 0; i < strokes_.size(); ++i) { + if (selectedIndices_.count(i) > 0) continue; + + const auto& stroke = strokes_[i]; + SkPaint strokePaint = stroke.paint; + if (!stroke.isEraser) { + uint8_t baseAlpha = stroke.paint.getAlpha(); + strokePaint.setAlpha(static_cast(baseAlpha * stroke.originalAlphaMod)); + } + + bool needsClipRestore = false; + if (!stroke.erasedBy.empty()) { + stroke.ensureEraserCacheValid(); + if (!stroke.cachedEraserPath.isEmpty()) { + canvas->save(); + canvas->clipPath(stroke.cachedEraserPath, SkClipOp::kDifference); + needsClipRestore = true; + } + } + + renderStrokeGeometry(canvas, stroke, strokePaint); + + if (needsClipRestore) canvas->restore(); + } + + nonSelectedSnapshot_ = nonSelectedSurface->makeImageSnapshot(); + + // Create snapshot of selected strokes + sk_sp selectedSurface = SkSurfaces::Raster(info); + if (!selectedSurface) return; + + canvas = selectedSurface->getCanvas(); + canvas->clear(SK_ColorTRANSPARENT); + + for (size_t idx : selectedIndices_) { + if (idx >= strokes_.size()) continue; + + const auto& stroke = strokes_[idx]; + SkPaint strokePaint = stroke.paint; + if (!stroke.isEraser) { + uint8_t baseAlpha = stroke.paint.getAlpha(); + strokePaint.setAlpha(static_cast(baseAlpha * stroke.originalAlphaMod)); + } + + bool needsClipRestore = false; + if (!stroke.erasedBy.empty()) { + stroke.ensureEraserCacheValid(); + if (!stroke.cachedEraserPath.isEmpty()) { + canvas->save(); + canvas->clipPath(stroke.cachedEraserPath, SkClipOp::kDifference); + needsClipRestore = true; + } + } + + renderStrokeGeometry(canvas, stroke, strokePaint); + + if (needsClipRestore) canvas->restore(); + } + + selectedSnapshot_ = selectedSurface->makeImageSnapshot(); + + // Cache the selection highlight (dashed outlines) + sk_sp highlightSurface = SkSurfaces::Raster(info); + if (highlightSurface) { + canvas = highlightSurface->getCanvas(); + canvas->clear(SK_ColorTRANSPARENT); + selection_->renderSelection(canvas, strokes_, selectedIndices_); + selectionHighlightSnapshot_ = highlightSurface->makeImageSnapshot(); + } + + hasDragCache_ = true; +} + +void SkiaDrawingEngine::beginSelectionDrag() { + std::lock_guard lock(stateMutex_); + + if (selectedIndices_.empty()) return; + + // Pre-cache if not already done + if (!hasDragCache_) { + prepareSelectionDragCache(); + } + + isDraggingSelection_ = true; + selectionOffsetX_ = 0.0f; + selectionOffsetY_ = 0.0f; +} + +void SkiaDrawingEngine::endSelectionDrag() { + std::lock_guard lock(stateMutex_); + + isDraggingSelection_ = false; + hasDragCache_ = false; + selectionOffsetX_ = 0.0f; + selectionOffsetY_ = 0.0f; + dragBackgroundSnapshot_ = nullptr; + nonSelectedSnapshot_ = nullptr; + selectedSnapshot_ = nullptr; + selectionHighlightSnapshot_ = nullptr; +} + +int SkiaDrawingEngine::getSelectionCount() const { + std::lock_guard lock(stateMutex_); + + return selection_->getSelectionCount(selectedIndices_); +} + +std::vector SkiaDrawingEngine::getSelectionBounds() { + std::lock_guard lock(stateMutex_); + + std::vector bounds = selection_->getSelectionBounds(strokes_, selectedIndices_); + if (bounds.size() >= 4 && isDraggingSelection_) { + bounds[0] += selectionOffsetX_; + bounds[1] += selectionOffsetY_; + bounds[2] += selectionOffsetX_; + bounds[3] += selectionOffsetY_; + } + return bounds; +} + +} // namespace nativedrawing diff --git a/cpp/StrokeSplitter.cpp b/cpp/StrokeSplitter.cpp index 88b5992..49b27c8 100644 --- a/cpp/StrokeSplitter.cpp +++ b/cpp/StrokeSplitter.cpp @@ -1,5 +1,6 @@ #include "StrokeSplitter.h" #include "PathRenderer.h" +#include "ShapeRecognition.h" #include namespace nativedrawing { diff --git a/cpp/StrokeSplitter.h b/cpp/StrokeSplitter.h index f4fddf8..f7d21f2 100644 --- a/cpp/StrokeSplitter.h +++ b/cpp/StrokeSplitter.h @@ -1,7 +1,7 @@ #pragma once #include -#include "SkiaDrawingEngine.h" +#include "DrawingTypes.h" namespace nativedrawing { diff --git a/example/package-lock.json b/example/package-lock.json index dc0664a..ae295fd 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -28,7 +28,7 @@ }, "..": { "name": "@mathnotes/mobile-ink", - "version": "0.1.0", + "version": "0.2.0", "license": "Apache-2.0", "devDependencies": { "@babel/core": "^7.25.2", diff --git a/package.json b/package.json index a650abc..d9c71c5 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "build": "bob build", "typecheck": "tsc --noEmit", "test": "jest --runInBand", - "test:native:smoke": "clang++ -std=c++20 scripts/drawing_serialization_smoke.cpp cpp/DrawingSerialization.cpp -I cpp -I node_modules/@shopify/react-native-skia/cpp/skia -I node_modules/@shopify/react-native-skia/cpp/skia/modules/pathops/include node_modules/@shopify/react-native-skia/libs/apple/libskia.xcframework/macos-arm64_x86_64/libskia.a node_modules/@shopify/react-native-skia/libs/apple/libpathops.xcframework/macos-arm64_x86_64/libpathops.a -framework ApplicationServices -framework CoreFoundation -framework CoreGraphics -framework CoreText -framework Foundation -framework QuartzCore -o /tmp/mobile_ink_drawing_serialization_smoke && /tmp/mobile_ink_drawing_serialization_smoke", + "test:native:smoke": "clang++ -std=c++20 scripts/drawing_serialization_smoke.cpp cpp/DrawingTypes.cpp cpp/DrawingSerialization.cpp cpp/ShapeRecognition.cpp -I cpp -I node_modules/@shopify/react-native-skia/cpp/skia -I node_modules/@shopify/react-native-skia/cpp/skia/modules/pathops/include node_modules/@shopify/react-native-skia/libs/apple/libskia.xcframework/macos-arm64_x86_64/libskia.a node_modules/@shopify/react-native-skia/libs/apple/libpathops.xcframework/macos-arm64_x86_64/libpathops.a -framework ApplicationServices -framework CoreFoundation -framework CoreGraphics -framework CoreText -framework Foundation -framework QuartzCore -o /tmp/mobile_ink_drawing_serialization_smoke && /tmp/mobile_ink_drawing_serialization_smoke", "test:example:typecheck": "npm --prefix example run typecheck", "test:example:export:ios": "npm --prefix example run export:ios", "pack:dry-run": "npm pack --dry-run",