From 77b6ee415d819276258271e61a812adfc833c9ea Mon Sep 17 00:00:00 2001 From: Akiba So Date: Tue, 23 Jun 2026 03:36:44 +0800 Subject: [PATCH] feat: host-agnostic StrokeHost (CoordinateSpaceHost, plan principle #2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lib/editor/engine/stroke_host.dart | 52 ++++++++++++++++++++++++++++++ test/stroke_host_test.dart | 45 ++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 lib/editor/engine/stroke_host.dart create mode 100644 test/stroke_host_test.dart diff --git a/lib/editor/engine/stroke_host.dart b/lib/editor/engine/stroke_host.dart new file mode 100644 index 0000000..3ea7b09 --- /dev/null +++ b/lib/editor/engine/stroke_host.dart @@ -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::page:"` 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 strokesIn(Rect viewportNormalized) { + return [ + for (final s in store.committed) + if (strokeIntersects(s, viewportNormalized)) s, + ]; + } + + @override + String toString() => 'StrokeHost($hostId, $contentSize, rev=$revision)'; +} diff --git a/test/stroke_host_test.dart b/test/stroke_host_test.dart new file mode 100644 index 0000000..834e519 --- /dev/null +++ b/test/stroke_host_test.dart @@ -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); + }); +}