feat: host-agnostic StrokeHost (CoordinateSpaceHost, plan principle #2)
Some checks failed
CI / Windows build (push) Has been cancelled
Some checks failed
CI / Windows build (push) Has been cancelled
Ties the engine together: a StrokeHost is anything ink attaches to — a PDF page, an infinite-board region, or a (P5) CAS overlay — with a stable hostId (cache + persistence key), a contentSize (normalized↔px mapping), and a revision-tracked StrokeStore. strokesIn(viewport) broad-phase-culls via stroke_bounds for the board. The viewport mounts one AnnotationLayer per host; nothing in the engine knows page vs board (the one host-agnostic ink engine). Makes StrokeStore (P0) + stroke_bounds load-bearing together. Pure; unit-tested. flutter analyze lib/editor clean; 208/208 tests (+4). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
52
lib/editor/engine/stroke_host.dart
Normal file
52
lib/editor/engine/stroke_host.dart
Normal file
@@ -0,0 +1,52 @@
|
||||
// lib/editor/engine/stroke_host.dart
|
||||
//
|
||||
// A CoordinateSpaceHost (plan principle #2: ONE host-agnostic ink engine). A
|
||||
// host is anything ink attaches to — a PDF page, an infinite board region, or a
|
||||
// (P5) CAS overlay — identified by [hostId], with a [contentSize] that defines
|
||||
// the normalized↔pixel mapping, and a committed [StrokeStore]. The viewport
|
||||
// mounts one AnnotationLayer per host; nothing in the engine knows whether it's
|
||||
// a page or a board.
|
||||
//
|
||||
// Pure (no widgets/pdfrx); ties together StrokeStore (P0) + stroke_bounds
|
||||
// broad-phase culling. Unit-tested.
|
||||
|
||||
import 'dart:ui' show Rect, Size;
|
||||
|
||||
import 'stroke_bounds.dart';
|
||||
import 'stroke_model.dart';
|
||||
import 'stroke_store.dart';
|
||||
|
||||
/// One ink host: identity + content geometry + its committed strokes.
|
||||
class StrokeHost {
|
||||
StrokeHost({
|
||||
required this.hostId,
|
||||
required this.contentSize,
|
||||
StrokeStore? store,
|
||||
}) : store = store ?? StrokeStore();
|
||||
|
||||
/// Stable id (e.g. `"doc:<id>:page:<n>"` or a board-region id) used as the
|
||||
/// ink Picture cache key prefix + the persistence host id.
|
||||
final String hostId;
|
||||
|
||||
/// Content size in logical px at scale 1; normalized [0,1] coords map onto it.
|
||||
final Size contentSize;
|
||||
|
||||
/// Committed strokes for this host (revision-tracked).
|
||||
final StrokeStore store;
|
||||
|
||||
/// Revision of the committed strokes (O(1) repaint gate passthrough).
|
||||
int get revision => store.revision;
|
||||
|
||||
/// Committed strokes whose bounds overlap [viewportNormalized] — broad-phase
|
||||
/// culling for the infinite board (skip off-screen strokes). For a bounded
|
||||
/// PDF page the whole page is usually in view, so callers can skip this.
|
||||
List<EditorStroke> strokesIn(Rect viewportNormalized) {
|
||||
return [
|
||||
for (final s in store.committed)
|
||||
if (strokeIntersects(s, viewportNormalized)) s,
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'StrokeHost($hostId, $contentSize, rev=$revision)';
|
||||
}
|
||||
45
test/stroke_host_test.dart
Normal file
45
test/stroke_host_test.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
// Tests for the host-agnostic ink host (plan principle #2).
|
||||
|
||||
import 'dart:ui' show Rect, Size;
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:badnote/editor/engine/stroke_host.dart';
|
||||
import 'package:badnote/editor/engine/stroke_model.dart';
|
||||
|
||||
EditorStroke _at(String id, double x, double y) => EditorStroke.create(
|
||||
id: id,
|
||||
points: [EditorPoint(x: x, y: y), EditorPoint(x: x + 0.05, y: y + 0.05)],
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('exposes identity, content size, and a fresh store', () {
|
||||
final host = StrokeHost(hostId: 'doc:a:page:0', contentSize: const Size(595, 842));
|
||||
expect(host.hostId, 'doc:a:page:0');
|
||||
expect(host.contentSize, const Size(595, 842));
|
||||
expect(host.revision, 0);
|
||||
});
|
||||
|
||||
test('revision tracks the store', () {
|
||||
final host = StrokeHost(hostId: 'h', contentSize: const Size(100, 100));
|
||||
host.store.add(_at('s1', 0.1, 0.1));
|
||||
expect(host.revision, 1);
|
||||
});
|
||||
|
||||
test('strokesIn culls strokes outside the viewport (board broad-phase)', () {
|
||||
final host = StrokeHost(hostId: 'board', contentSize: const Size(1000, 1000));
|
||||
host.store
|
||||
..add(_at('in', 0.1, 0.1)) // inside [0,0.5]
|
||||
..add(_at('out', 0.8, 0.8)) // outside
|
||||
..add(_at('edge', 0.48, 0.48)); // partial overlap with [0,0.5]
|
||||
final visible = host.strokesIn(const Rect.fromLTRB(0, 0, 0.5, 0.5));
|
||||
expect(visible.map((s) => s.id).toSet(), {'in', 'edge'});
|
||||
});
|
||||
|
||||
test('an injected store is adopted (load path)', () {
|
||||
final host = StrokeHost(hostId: 'h', contentSize: const Size(10, 10));
|
||||
host.store.replaceAll([_at('a', 0.1, 0.1), _at('b', 0.2, 0.2)]);
|
||||
expect(host.store.committed.length, 2);
|
||||
expect(host.strokesIn(const Rect.fromLTRB(0, 0, 1, 1)).length, 2);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user