feat(perf): RDP stroke simplification for storage/render compaction (R1/R10)
Some checks failed
CI / Windows build (push) Has been cancelled
Some checks failed
CI / Windows build (push) Has been cancelled
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) <noreply@anthropic.com>
This commit is contained in:
80
lib/editor/engine/stroke_simplify.dart
Normal file
80
lib/editor/engine/stroke_simplify.dart
Normal file
@@ -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<bool>.filled(pts.length, false);
|
||||||
|
keep[0] = true;
|
||||||
|
keep[pts.length - 1] = true;
|
||||||
|
_rdp(pts, 0, pts.length - 1, tolerance * tolerance, keep);
|
||||||
|
|
||||||
|
final reduced = <EditorPoint>[
|
||||||
|
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<EditorPoint> pts,
|
||||||
|
int first,
|
||||||
|
int last,
|
||||||
|
double tolSq,
|
||||||
|
List<bool> 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;
|
||||||
|
}
|
||||||
104
test/stroke_simplify_test.dart
Normal file
104
test/stroke_simplify_test.dart
Normal file
@@ -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<List<double>> pts, {List<double?>? 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user