Flutter 手写引擎 (Phase F3): - stroke_model.dart: 笔画数据模型 (StrokePoint/Stroke/BrushType) - stroke_renderer.dart: perfect_freehand 渲染管线 + 四画笔参数 - handwriting_canvas.dart: Listener 输入 + 掌心抑制 + 去抖过滤 - editor_bloc.dart: BLoC 状态管理 + 撤销/重做 (50步) Rust 日记 CRUD + 同步 (Phase B2): - journal_service.rs: CRUD + 软删除 + 分页列表 + 事件发布 - sync_service.rs: 版本号同步 + 冲突检测 - journal_handler.rs: 5个API端点 + utoipa注解 + 权限守卫 - sync_handler.rs: 同步API端点 - error.rs: From<DiaryError> for AppError + 8个单元测试 - 路由注册: /diary/journals + /diary/sync 验证: - cargo check: 0 error - cargo test: 433 测试全通过 - flutter analyze: 1 warning (unused private param)
236 lines
7.1 KiB
Dart
236 lines
7.1 KiB
Dart
// 笔画渲染器 — 将 StrokePoint 列表转换为 Flutter 可绘制路径。
|
||
//
|
||
// 核心流程:
|
||
// StrokePoint[] → perfect_freehand.getStroke() → Point[] (轮廓多边形)
|
||
// → buildStrokePath() → Path → CustomPainter.drawPath()
|
||
//
|
||
// 每种画笔(pen/pencil/marker/eraser)拥有独立的 perfect_freehand 参数,
|
||
// 产生不同的视觉效果。
|
||
|
||
import 'package:flutter/widgets.dart';
|
||
import 'package:perfect_freehand/perfect_freehand.dart' as pf;
|
||
|
||
import 'stroke_model.dart';
|
||
|
||
// ============================================================
|
||
// 画笔参数配置
|
||
// ============================================================
|
||
|
||
/// perfect_freehand 参数集,按画笔类型区分。
|
||
///
|
||
/// 参考设计规格 v1.2:钢笔细致、铅笔柔和、马克笔粗半透明、橡皮擦大范围。
|
||
class _BrushConfig {
|
||
final double size;
|
||
final double thinning;
|
||
final double smoothing;
|
||
final double streamline;
|
||
final bool simulatePressure;
|
||
|
||
const _BrushConfig({
|
||
required this.size,
|
||
required this.thinning,
|
||
required this.smoothing,
|
||
this.streamline = 0.5,
|
||
required this.simulatePressure,
|
||
});
|
||
}
|
||
|
||
/// 各画笔的渲染参数。
|
||
const Map<BrushType, _BrushConfig> _brushConfigs = {
|
||
/// 钢笔:中等粗细,强压感变化,模拟毛笔效果
|
||
BrushType.pen: _BrushConfig(
|
||
size: 8,
|
||
thinning: 0.7,
|
||
smoothing: 0.5,
|
||
simulatePressure: true,
|
||
),
|
||
|
||
/// 铅笔:细线,轻微压感,高平滑度产生自然线条
|
||
BrushType.pencil: _BrushConfig(
|
||
size: 4,
|
||
thinning: 0.3,
|
||
smoothing: 0.7,
|
||
simulatePressure: true,
|
||
),
|
||
|
||
/// 马克笔:粗线,几乎无压感变化,不模拟压力
|
||
BrushType.marker: _BrushConfig(
|
||
size: 16,
|
||
thinning: 0.1,
|
||
smoothing: 0.5,
|
||
simulatePressure: false,
|
||
),
|
||
|
||
/// 橡皮擦:最大范围,无压感变化
|
||
BrushType.eraser: _BrushConfig(
|
||
size: 32,
|
||
thinning: 0,
|
||
smoothing: 0.5,
|
||
simulatePressure: false,
|
||
),
|
||
};
|
||
|
||
// ============================================================
|
||
// 点转换工具函数
|
||
// ============================================================
|
||
|
||
/// 将 [StrokePoint] 列表转换为 perfect_freehand 轮廓点列表。
|
||
///
|
||
/// [brushType] 决定渲染参数,[width] 作为乘数影响最终笔画大小。
|
||
/// 当 [points] 少于 2 个时返回空列表(无法构成笔画)。
|
||
List<Offset> pointsToOutline(
|
||
List<StrokePoint> points,
|
||
BrushType brushType,
|
||
double width,
|
||
) {
|
||
if (points.length < 2) return const [];
|
||
|
||
final config = _brushConfigs[brushType]!;
|
||
final widthMultiplier = width / 3.0; // 3.0 是默认宽度,缩放到用户选择
|
||
|
||
// 转换为 perfect_freehand 的 Point 格式
|
||
final pfPoints = points
|
||
.map((p) => pf.Point(p.x, p.y, p.pressure))
|
||
.toList(growable: false);
|
||
|
||
// 调用 getStroke 获取轮廓多边形
|
||
final outline = pf.getStroke(
|
||
pfPoints,
|
||
size: config.size * widthMultiplier,
|
||
thinning: config.thinning,
|
||
smoothing: config.smoothing,
|
||
streamline: config.streamline,
|
||
simulatePressure: config.simulatePressure,
|
||
isComplete: true, // 已完成笔画,启用端点处理
|
||
);
|
||
|
||
// 转换为 Flutter Offset
|
||
return outline.map((p) => Offset(p.x, p.y)).toList(growable: false);
|
||
}
|
||
|
||
// ============================================================
|
||
// 路径构建
|
||
// ============================================================
|
||
|
||
/// 将轮廓点列表构建为 Flutter [Path]。
|
||
///
|
||
/// 使用 [Path.addPolygon] 直接构建闭合多边形,
|
||
/// 比 lineTo 逐点连接更高效,且自动闭合。
|
||
Path buildStrokePath(List<Offset> outlinePoints) {
|
||
if (outlinePoints.isEmpty) return Path();
|
||
|
||
final path = Path();
|
||
path.addPolygon(outlinePoints, true); // true = 自动闭合
|
||
return path;
|
||
}
|
||
|
||
// ============================================================
|
||
// 颜色解析工具
|
||
// ============================================================
|
||
|
||
/// 将 CSS 十六进制颜色字符串 (#RRGGBB) 解析为 Flutter [Color]。
|
||
///
|
||
/// 如果解析失败,回退到默认的深色文字色 #2D2420。
|
||
Color _parseHexColor(String hex) {
|
||
final hexStr = hex.replaceFirst('#', '');
|
||
if (hexStr.length != 6) return const Color(0xFF2D2420);
|
||
|
||
final value = int.tryParse(hexStr, radix: 16);
|
||
if (value == null) return const Color(0xFF2D2420);
|
||
|
||
return Color(0xFF000000 + value);
|
||
}
|
||
|
||
// ============================================================
|
||
// 笔画绘制器
|
||
// ============================================================
|
||
|
||
/// 自定义 [CustomPainter],负责将所有笔画渲染到 Canvas。
|
||
///
|
||
/// 接收 [completedStrokes](已完成笔画列表)和可选的 [currentStroke]
|
||
/// (正在绘制中的笔画),分别渲染。
|
||
///
|
||
/// 渲染策略:
|
||
/// - 钢笔/铅笔:正常不透明绘制
|
||
/// - 马克笔:半透明 (0.4 opacity) 模拟荧光笔效果
|
||
/// - 橡皮擦:使用 [BlendMode.dstOut] 擦除底层内容
|
||
class StrokePainter extends CustomPainter {
|
||
final List<Stroke> completedStrokes;
|
||
final Stroke? currentStroke;
|
||
|
||
StrokePainter({
|
||
required this.completedStrokes,
|
||
this.currentStroke,
|
||
});
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
// 先绘制已完成的笔画
|
||
for (final stroke in completedStrokes) {
|
||
_drawStroke(canvas, stroke);
|
||
}
|
||
|
||
// 再绘制正在进行的笔画(覆盖在已完成笔画之上)
|
||
if (currentStroke != null) {
|
||
_drawStroke(canvas, currentStroke!);
|
||
}
|
||
}
|
||
|
||
/// 渲染单条笔画。
|
||
void _drawStroke(Canvas canvas, Stroke stroke) {
|
||
final outlinePoints = pointsToOutline(
|
||
stroke.points,
|
||
stroke.brushType,
|
||
stroke.width,
|
||
);
|
||
if (outlinePoints.isEmpty) return;
|
||
|
||
final path = buildStrokePath(outlinePoints);
|
||
|
||
// 根据画笔类型选择不同的绘制策略
|
||
final paint = _createPaint(stroke);
|
||
canvas.drawPath(path, paint);
|
||
}
|
||
|
||
/// 根据画笔类型创建对应的 [Paint] 对象。
|
||
Paint _createPaint(Stroke stroke) {
|
||
final color = _parseHexColor(stroke.color);
|
||
|
||
switch (stroke.brushType) {
|
||
case BrushType.pen:
|
||
case BrushType.pencil:
|
||
// 钢笔和铅笔:不透明实心绘制
|
||
return Paint()
|
||
..color = color
|
||
..style = PaintingStyle.fill
|
||
..isAntiAlias = true;
|
||
|
||
case BrushType.marker:
|
||
// 马克笔:半透明模拟荧光笔
|
||
return Paint()
|
||
..color = color.withValues(alpha: 0.4)
|
||
..style = PaintingStyle.fill
|
||
..isAntiAlias = true;
|
||
|
||
case BrushType.eraser:
|
||
// 橡皮擦:使用 dstOut 混合模式擦除底层像素
|
||
return Paint()
|
||
..color = const Color(0xFFFFFFFF) // 颜色无关紧要,blendMode 决定效果
|
||
..style = PaintingStyle.fill
|
||
..blendMode = BlendMode.dstOut
|
||
..isAntiAlias = true;
|
||
}
|
||
}
|
||
|
||
/// 判断是否需要重绘。
|
||
///
|
||
/// 始终比较引用:当 strokes 列表或 currentStroke 发生变化时重绘。
|
||
/// 这是最安全的策略,因为笔画数据是不可变的(freezed),
|
||
/// 引用变化意味着内容一定变化。
|
||
@override
|
||
bool shouldRepaint(StrokePainter oldDelegate) {
|
||
return !identical(completedStrokes, oldDelegate.completedStrokes) ||
|
||
!identical(currentStroke, oldDelegate.currentStroke);
|
||
}
|
||
}
|