feat: stroke spatial bounds + broad-phase visibility (board culling)
Some checks failed
CI / Windows build (push) Has been cancelled
Some checks failed
CI / Windows build (push) Has been cancelled
strokeBounds (tight AABB over normalized points, null for empty, zero-size for a single point), strokesBounds (union), and strokeIntersects (does a stroke's box overlap a viewport rect — touching edges count). Broad-phase primitive for the infinite board: skip painting/erasing/hit-testing strokes off-screen (R1 perf), and a cheap pre-filter before the exact per-point eraser test. Pure geometry over EditorStroke; fully unit-tested. flutter analyze lib/editor clean; 191/191 tests (+10). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
50
lib/editor/engine/stroke_bounds.dart
Normal file
50
lib/editor/engine/stroke_bounds.dart
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
// lib/editor/engine/stroke_bounds.dart
|
||||||
|
//
|
||||||
|
// Axis-aligned bounds of strokes in normalized content coordinates. Used for
|
||||||
|
// broad-phase culling (don't paint/erase/hit-test strokes whose box is off the
|
||||||
|
// viewport — the infinite board's R1 perf primitive), and as a cheap pre-filter
|
||||||
|
// before the exact per-point eraser test.
|
||||||
|
//
|
||||||
|
// Pure geometry over EditorStroke; no widgets/storage; fully unit-tested.
|
||||||
|
|
||||||
|
import 'dart:ui' show Rect;
|
||||||
|
|
||||||
|
import 'stroke_model.dart';
|
||||||
|
|
||||||
|
/// Tight axis-aligned bounds of [stroke] in normalized coords, or null when the
|
||||||
|
/// stroke has no points. A single-point stroke yields a zero-size rect at that
|
||||||
|
/// point.
|
||||||
|
Rect? strokeBounds(EditorStroke stroke) {
|
||||||
|
if (stroke.points.isEmpty) return null;
|
||||||
|
var minX = double.infinity, minY = double.infinity;
|
||||||
|
var maxX = double.negativeInfinity, maxY = double.negativeInfinity;
|
||||||
|
for (final p in stroke.points) {
|
||||||
|
if (p.x < minX) minX = p.x;
|
||||||
|
if (p.y < minY) minY = p.y;
|
||||||
|
if (p.x > maxX) maxX = p.x;
|
||||||
|
if (p.y > maxY) maxY = p.y;
|
||||||
|
}
|
||||||
|
return Rect.fromLTRB(minX, minY, maxX, maxY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Union bounds of [strokes], or null when none have points.
|
||||||
|
Rect? strokesBounds(Iterable<EditorStroke> strokes) {
|
||||||
|
Rect? acc;
|
||||||
|
for (final stroke in strokes) {
|
||||||
|
final b = strokeBounds(stroke);
|
||||||
|
if (b == null) continue;
|
||||||
|
acc = acc == null ? b : acc.expandToInclude(b);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether [stroke]'s bounds overlap [viewport] (broad-phase visibility test).
|
||||||
|
/// Empty strokes are never visible. Touching edges count as overlapping.
|
||||||
|
bool strokeIntersects(EditorStroke stroke, Rect viewport) {
|
||||||
|
final b = strokeBounds(stroke);
|
||||||
|
if (b == null) return false;
|
||||||
|
return b.left <= viewport.right &&
|
||||||
|
b.right >= viewport.left &&
|
||||||
|
b.top <= viewport.bottom &&
|
||||||
|
b.bottom >= viewport.top;
|
||||||
|
}
|
||||||
97
test/stroke_bounds_test.dart
Normal file
97
test/stroke_bounds_test.dart
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
// Tests for stroke spatial bounds + broad-phase visibility (F7 board culling).
|
||||||
|
|
||||||
|
import 'dart:ui' show Rect;
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:badnote/editor/engine/stroke_model.dart';
|
||||||
|
import 'package:badnote/editor/engine/stroke_bounds.dart';
|
||||||
|
|
||||||
|
EditorStroke _stroke(List<List<double>> pts) => EditorStroke.create(
|
||||||
|
id: 's',
|
||||||
|
points: [for (final p in pts) EditorPoint(x: p[0], y: p[1])],
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('strokeBounds is the tight box over points', () {
|
||||||
|
final b = strokeBounds(_stroke([
|
||||||
|
[0.1, 0.2],
|
||||||
|
[0.5, 0.1],
|
||||||
|
[0.3, 0.6],
|
||||||
|
]))!;
|
||||||
|
expect(b.left, closeTo(0.1, 1e-9));
|
||||||
|
expect(b.top, closeTo(0.1, 1e-9));
|
||||||
|
expect(b.right, closeTo(0.5, 1e-9));
|
||||||
|
expect(b.bottom, closeTo(0.6, 1e-9));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty stroke → null', () {
|
||||||
|
expect(strokeBounds(_stroke([])), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('single point → zero-size rect at that point', () {
|
||||||
|
final b = strokeBounds(_stroke([
|
||||||
|
[0.4, 0.7],
|
||||||
|
]))!;
|
||||||
|
expect(b.width, 0);
|
||||||
|
expect(b.height, 0);
|
||||||
|
expect(b.left, closeTo(0.4, 1e-9));
|
||||||
|
expect(b.top, closeTo(0.7, 1e-9));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('strokesBounds is the union; skips empty strokes', () {
|
||||||
|
final b = strokesBounds([
|
||||||
|
_stroke([
|
||||||
|
[0.1, 0.1],
|
||||||
|
[0.2, 0.2],
|
||||||
|
]),
|
||||||
|
_stroke([]), // skipped
|
||||||
|
_stroke([
|
||||||
|
[0.8, 0.7],
|
||||||
|
]),
|
||||||
|
])!;
|
||||||
|
expect(b.left, closeTo(0.1, 1e-9));
|
||||||
|
expect(b.top, closeTo(0.1, 1e-9));
|
||||||
|
expect(b.right, closeTo(0.8, 1e-9));
|
||||||
|
expect(b.bottom, closeTo(0.7, 1e-9));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('strokesBounds of all-empty → null', () {
|
||||||
|
expect(strokesBounds([_stroke([]), _stroke([])]), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('strokeIntersects (broad-phase)', () {
|
||||||
|
final view = const Rect.fromLTRB(0.0, 0.0, 0.5, 0.5);
|
||||||
|
|
||||||
|
test('inside the viewport intersects', () {
|
||||||
|
expect(strokeIntersects(_stroke([
|
||||||
|
[0.1, 0.1],
|
||||||
|
[0.2, 0.2],
|
||||||
|
]), view), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fully outside does NOT intersect', () {
|
||||||
|
expect(strokeIntersects(_stroke([
|
||||||
|
[0.8, 0.8],
|
||||||
|
[0.9, 0.9],
|
||||||
|
]), view), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('partial overlap intersects', () {
|
||||||
|
expect(strokeIntersects(_stroke([
|
||||||
|
[0.4, 0.4],
|
||||||
|
[0.7, 0.7],
|
||||||
|
]), view), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('touching edge counts as intersecting', () {
|
||||||
|
expect(strokeIntersects(_stroke([
|
||||||
|
[0.5, 0.5],
|
||||||
|
]), view), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty stroke is never visible', () {
|
||||||
|
expect(strokeIntersects(_stroke([]), view), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user