From b359000991dba11dc4e6af538334354f97be5767 Mon Sep 17 00:00:00 2001 From: Akiba So Date: Tue, 23 Jun 2026 03:31:54 +0800 Subject: [PATCH] feat: stroke spatial bounds + broad-phase visibility (board culling) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lib/editor/engine/stroke_bounds.dart | 50 ++++++++++++++ test/stroke_bounds_test.dart | 97 ++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 lib/editor/engine/stroke_bounds.dart create mode 100644 test/stroke_bounds_test.dart diff --git a/lib/editor/engine/stroke_bounds.dart b/lib/editor/engine/stroke_bounds.dart new file mode 100644 index 0000000..b28e22c --- /dev/null +++ b/lib/editor/engine/stroke_bounds.dart @@ -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 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; +} diff --git a/test/stroke_bounds_test.dart b/test/stroke_bounds_test.dart new file mode 100644 index 0000000..6cdd4bc --- /dev/null +++ b/test/stroke_bounds_test.dart @@ -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> 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); + }); + }); +}