From 1e836d6e7dbf2784fb0a89c2dfccc2cf5b4c862a Mon Sep 17 00:00:00 2001 From: Akiba So Date: Tue, 23 Jun 2026 03:33:39 +0800 Subject: [PATCH] feat(perf): RDP stroke simplification for storage/render compaction (R1/R10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit simplifyStroke reduces a stroke's points via Ramer–Douglas–Peucker at a normalized perpendicular-distance tolerance: a fast Surface-Pen stroke drops hundreds of near-collinear samples with no visible change, shrinking the DB row and speeding re-rasterization (R1/R10). Endpoints + significant vertices kept; pressure/tilt + color/width/tool/id preserved; <=2 points or tol<=0 are no-ops (returns the same instance). The commit path can call it before saveHost; the live in-progress stroke stays untouched. Pure geometry over EditorStroke; fully unit-tested (collinear collapse, peak retention, within-tolerance drop, metadata preservation). flutter analyze lib/editor clean; 198/198 tests (+7). Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/editor/engine/stroke_simplify.dart | 80 +++++++++++++++++++ test/stroke_simplify_test.dart | 104 +++++++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 lib/editor/engine/stroke_simplify.dart create mode 100644 test/stroke_simplify_test.dart diff --git a/lib/editor/engine/stroke_simplify.dart b/lib/editor/engine/stroke_simplify.dart new file mode 100644 index 0000000..e452cf5 --- /dev/null +++ b/lib/editor/engine/stroke_simplify.dart @@ -0,0 +1,80 @@ +// lib/editor/engine/stroke_simplify.dart +// +// Ramer–Douglas–Peucker stroke point reduction. A fast Surface-Pen stroke can +// land hundreds of nearly-collinear samples; thinning them before persistence +// shrinks the DB row + speeds re-rasterization (R1/R10 perf) with no visible +// change. Endpoints + perceptually-significant vertices are kept; pressure/tilt +// ride along on the retained points. +// +// Pure geometry over EditorStroke (normalized coords); fully unit-tested. The +// commit path can call this before saveHost; the live in-progress stroke is left +// untouched so drawing stays crisp. + +import 'stroke_model.dart'; + +/// Returns [stroke] with its points reduced by RDP at [tolerance] (perpendicular +/// distance in normalized units; larger = more aggressive). Strokes with <= 2 +/// points, or a non-positive tolerance, are returned unchanged. +EditorStroke simplifyStroke(EditorStroke stroke, {double tolerance = 0.0008}) { + final pts = stroke.points; + if (pts.length <= 2 || tolerance <= 0) return stroke; + + final keep = List.filled(pts.length, false); + keep[0] = true; + keep[pts.length - 1] = true; + _rdp(pts, 0, pts.length - 1, tolerance * tolerance, keep); + + final reduced = [ + for (var i = 0; i < pts.length; i++) + if (keep[i]) pts[i], + ]; + if (reduced.length == pts.length) return stroke; + return stroke.copyWith(points: reduced); +} + +// Iterative-friendly recursion over the index range [first, last]. +void _rdp( + List pts, + int first, + int last, + double tolSq, + List keep, +) { + if (last <= first + 1) return; + var maxDistSq = 0.0; + var index = -1; + final ax = pts[first].x, ay = pts[first].y; + final bx = pts[last].x, by = pts[last].y; + for (var i = first + 1; i < last; i++) { + final d = _perpDistSq(pts[i].x, pts[i].y, ax, ay, bx, by); + if (d > maxDistSq) { + maxDistSq = d; + index = i; + } + } + if (maxDistSq > tolSq && index != -1) { + keep[index] = true; + _rdp(pts, first, index, tolSq, keep); + _rdp(pts, index, last, tolSq, keep); + } +} + +/// Squared perpendicular distance of (px,py) from the segment (ax,ay)-(bx,by). +/// Degenerate segment (a==b) falls back to squared distance to the point. +double _perpDistSq( + double px, + double py, + double ax, + double ay, + double bx, + double by, +) { + final dx = bx - ax, dy = by - ay; + final lenSq = dx * dx + dy * dy; + if (lenSq == 0) { + final ex = px - ax, ey = py - ay; + return ex * ex + ey * ey; + } + final cross = (px - ax) * dy - (py - ay) * dx; + return (cross * cross) / lenSq; +} diff --git a/test/stroke_simplify_test.dart b/test/stroke_simplify_test.dart new file mode 100644 index 0000000..537f66b --- /dev/null +++ b/test/stroke_simplify_test.dart @@ -0,0 +1,104 @@ +// Tests for RDP stroke simplification (R1/R10 perf). + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:badnote/editor/engine/stroke_model.dart'; +import 'package:badnote/editor/engine/stroke_simplify.dart'; + +EditorStroke _stroke(List> pts, {List? pressures}) => + EditorStroke.create( + id: 's', + points: [ + for (var i = 0; i < pts.length; i++) + EditorPoint( + x: pts[i][0], + y: pts[i][1], + pressure: pressures?[i], + ), + ], + ); + +void main() { + test('collinear points collapse to the two endpoints', () { + final s = _stroke([ + [0.0, 0.0], + [0.1, 0.1], + [0.2, 0.2], + [0.3, 0.3], + ]); + final out = simplifyStroke(s, tolerance: 0.0008); + expect(out.points.length, 2); + expect(out.points.first.x, 0.0); + expect(out.points.last.x, 0.3); + }); + + test('a perceptually significant peak is kept', () { + final s = _stroke([ + [0.0, 0.0], + [0.5, 0.5], // far off the (0,0)-(1,0) line → kept + [1.0, 0.0], + ]); + final out = simplifyStroke(s, tolerance: 0.0008); + expect(out.points.length, 3); + }); + + test('a deviation within tolerance is dropped', () { + final s = _stroke([ + [0.0, 0.0], + [0.5, 0.0001], // perp dist 0.0001 < 0.0008 → dropped + [1.0, 0.0], + ]); + final out = simplifyStroke(s, tolerance: 0.0008); + expect(out.points.length, 2); + }); + + test('<= 2 points are returned unchanged (same instance)', () { + final s = _stroke([ + [0.1, 0.1], + [0.2, 0.2], + ]); + expect(identical(simplifyStroke(s), s), isTrue); + }); + + test('non-positive tolerance is a no-op', () { + final s = _stroke([ + [0.0, 0.0], + [0.1, 0.11], + [0.2, 0.2], + ]); + expect(identical(simplifyStroke(s, tolerance: 0), s), isTrue); + }); + + test('pressure rides along on retained points', () { + final s = _stroke([ + [0.0, 0.0], + [0.5, 0.5], + [1.0, 0.0], + ], pressures: [ + 0.2, + 0.9, + 0.3, + ]); + final out = simplifyStroke(s, tolerance: 0.0008); + expect(out.points.map((p) => p.pressure), [0.2, 0.9, 0.3]); + }); + + test('metadata (color/width/tool/id) is preserved', () { + final s = EditorStroke.create( + id: 'keep-me', + color: 0xFF112233, + width: 0.02, + tool: EditorTool.highlighter, + points: [ + const EditorPoint(x: 0, y: 0), + const EditorPoint(x: 0.1, y: 0.1), + const EditorPoint(x: 0.2, y: 0.2), + ], + ); + final out = simplifyStroke(s, tolerance: 0.0008); + expect(out.id, 'keep-me'); + expect(out.color, 0xFF112233); + expect(out.width, 0.02); + expect(out.tool, EditorTool.highlighter); + }); +}