feat(engine): P0 stroke engine + persistence
Per the full-refactor plan §9 (input-independent half of P0): - engine: canonical EditorStroke (lossless InkStroke round-trip) + stroke_geometry (single getStroke outline) + revision-gated StrokeStore - render: static/live ink painters + ink_picture_cache (revision-keyed) + annotation_layer (RepaintBoundary) - persistence: DB v6 (ink, notebook_pages) + editor_repository diff-write (UPSERT changed / DELETE removed in one txn; id-set after commit) + save_scheduler - pdf_service export now FILLS the getStroke outline (R7 hairline fix) Not yet wired into the live editor (input relocation pending pen-pressure diagnostic). 28 new tests pass.
This commit is contained in:
68
lib/editor/engine/stroke_geometry.dart
Normal file
68
lib/editor/engine/stroke_geometry.dart
Normal file
@@ -0,0 +1,68 @@
|
||||
// lib/editor/engine/stroke_geometry.dart
|
||||
//
|
||||
// Single source of stroke outline geometry for both screen render and export.
|
||||
// The recipe is lifted verbatim from the proven live
|
||||
// `ink_painters.buildStrokePath` (lib/editor/canvas/ink_painters.dart): points
|
||||
// are scaled from normalized page coords to pixels, perfect_freehand produces
|
||||
// the outline, and a closed fill Path is built. Keeping ONE implementation here
|
||||
// kills the hairline-export divergence (R7).
|
||||
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
|
||||
|
||||
import 'stroke_model.dart';
|
||||
|
||||
/// Builds a closed, fillable outline [Path] for one [stroke], scaled into the
|
||||
/// pixel space of [pageSize] (which maps normalized [0,1] coords to pixels).
|
||||
///
|
||||
/// [isComplete] should be false for the in-progress live stroke so freehand
|
||||
/// tapers the trailing end correctly, and true for committed strokes.
|
||||
///
|
||||
/// Returns an empty [Path] when the stroke has no points (or freehand produces
|
||||
/// no outline).
|
||||
Path buildStrokeOutline(
|
||||
EditorStroke stroke,
|
||||
Size pageSize, {
|
||||
required bool isComplete,
|
||||
}) {
|
||||
final path = Path();
|
||||
if (stroke.points.isEmpty) return path;
|
||||
|
||||
final pixelWidth = stroke.width * pageSize.width;
|
||||
|
||||
final hasRealPressure = stroke.points.any((p) => p.pressure != null);
|
||||
final isHighlighter = stroke.tool == EditorTool.highlighter;
|
||||
|
||||
final pfPoints = stroke.points
|
||||
.map(
|
||||
(p) => pf.Point(
|
||||
p.x * pageSize.width,
|
||||
p.y * pageSize.height,
|
||||
p.pressure ?? 0.5,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
final outline = pf.getStroke(
|
||||
pfPoints,
|
||||
size: pixelWidth,
|
||||
// Highlighter keeps a constant width (no thinning); pen thins (0.7),
|
||||
// matching the live recipe.
|
||||
thinning: isHighlighter ? 0.0 : 0.7,
|
||||
smoothing: 0.5,
|
||||
streamline: 0.5,
|
||||
// Real stylus pressure -> don't simulate; no pressure -> let freehand fake
|
||||
// it based on velocity (highlighter never simulates).
|
||||
simulatePressure: !hasRealPressure && !isHighlighter,
|
||||
isComplete: isComplete,
|
||||
);
|
||||
|
||||
if (outline.isEmpty) return path;
|
||||
path.moveTo(outline.first.x, outline.first.y);
|
||||
for (var i = 1; i < outline.length; i++) {
|
||||
path.lineTo(outline[i].x, outline[i].y);
|
||||
}
|
||||
path.close();
|
||||
return path;
|
||||
}
|
||||
Reference in New Issue
Block a user