Some checks failed
CI / Windows build (push) Has been cancelled
Closes TODO(brush-persist). EditorStroke now serializes its brush as the stable BrushKind name; sidecars written before this field, and any unknown name, load as fountainPen (back-compat). PenStroke<->EditorStroke carry brush both ways, so a ballpoint/highlighter/pencil stroke keeps its opacity/blend after a document is closed and reopened. Note: InkStroke (the note/scratchpad world-coord format) has no brush field, so notes derive brush from the tool — highlighter is preserved, ballpoint/pencil collapse to fountainPen on reload (TODO: extend InkStroke). PDF documents persist brush fully. analyze clean, 379 tests green.
292 lines
12 KiB
Dart
292 lines
12 KiB
Dart
// lib/editor/engine/brush.dart
|
|
//
|
|
// Data-driven, Krita-compatible brush model — the extensibility seam for the
|
|
// pen engine. Each [BrushKind] maps to an immutable [BrushProfile] that fully
|
|
// describes how a stroke is captured (pressure pre-warp) and rendered
|
|
// (perfect_freehand geometry params + caps/taper). Adding a brush = adding one
|
|
// const entry to [kBrushPresets]; no render-path branching.
|
|
//
|
|
// Mirrors Krita's sensor→curve design (Pixel brush: each property is driven by
|
|
// a sensor through a response curve). Here the response curve is a pure power
|
|
// law `p^gamma` applied to pressure BEFORE perfect_freehand (rnote's
|
|
// `PressureCurve`: Pow2 = quadratic, Sqrt = √p), and the geometry knobs are
|
|
// perfect_freehand's `thinning`/`streamline`/`smoothing`/caps. A future `.kpp`
|
|
// (Krita brush preset) importer can produce [BrushProfile]s from the same
|
|
// fields — see TODO(brush-kpp-import).
|
|
//
|
|
// Source spec: docs/research/pen-brush-spec.md §1 (rnote pressure curve) and §4
|
|
// (per-brush perfect_freehand option tables). The numbers below are lifted from
|
|
// that spec verbatim.
|
|
|
|
import 'dart:ui' show Color, BlendMode;
|
|
|
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
|
|
|
/// The four selectable brushes. Extensible: add a kind here + a preset in
|
|
/// [kBrushPresets]. The eraser is NOT a brush — it stays a separate tool.
|
|
///
|
|
/// The `@JsonValue` names are the STABLE on-disk identifiers persisted in the
|
|
/// sidecar (`EditorStroke.brush`); they are decoupled from the Dart enum
|
|
/// identifiers so renaming a constant here never breaks existing sidecars. A
|
|
/// brush whose stored name is unknown (e.g. a future brush opened by an older
|
|
/// build) is read back as [fountainPen] (see `EditorStroke.brush`'s JsonKey).
|
|
enum BrushKind {
|
|
/// Strong pressure→width (rnote Pow2 / quadratic), soft taper, solid ink.
|
|
@JsonValue('fountainPen')
|
|
fountainPen,
|
|
|
|
/// Near-constant thin width; pressure carries OPACITY (the ballpoint "tell").
|
|
@JsonValue('ballpoint')
|
|
ballpoint,
|
|
|
|
/// Broad, flat width, translucent, square (uncapped) ends.
|
|
@JsonValue('highlighter')
|
|
highlighter,
|
|
|
|
/// Moderate width + opacity from pressure (rnote Sqrt / √p), scratchy.
|
|
@JsonValue('pencil')
|
|
pencil,
|
|
}
|
|
|
|
/// Immutable, const description of one brush.
|
|
///
|
|
/// The capture path reads [pressureGamma] (the rnote power-law warp applied via
|
|
/// `PressureCurve(gamma: pressureGamma)` BEFORE perfect_freehand) and the render
|
|
/// path reads the perfect_freehand geometry fields ([pfThinning], [pfStreamline],
|
|
/// [pfSmoothing], [simulatePressure]) plus the cap/taper flags.
|
|
///
|
|
/// [opacity] / [blendMultiply] drive the painters' compositing via
|
|
/// [resolveStrokePaint] (closes TODO(brush-opacity)): opacity is multiplied
|
|
/// into the stroke color's alpha (pressure-tied for ballpoint/pencil — see
|
|
/// [resolveStrokeOpacity]) and [blendMultiply] selects [BlendMode.multiply].
|
|
class BrushProfile {
|
|
const BrushProfile({
|
|
required this.kind,
|
|
required this.baseWidthFraction,
|
|
required this.pressureGamma,
|
|
required this.pfThinning,
|
|
required this.pfStreamline,
|
|
required this.pfSmoothing,
|
|
required this.simulatePressure,
|
|
required this.capStart,
|
|
required this.capEnd,
|
|
required this.taper,
|
|
required this.opacity,
|
|
required this.blendMultiply,
|
|
});
|
|
|
|
/// Which brush this profile is for.
|
|
final BrushKind kind;
|
|
|
|
/// Suggested base stroke width as a fraction of page width (so it scales with
|
|
/// zoom, matching `PenStroke.width`). The editors may override with their own
|
|
/// configured pen/highlighter widths; this is the spec's nominal default
|
|
/// (spec §4 diameters, expressed as a page-width fraction).
|
|
final double baseWidthFraction;
|
|
|
|
/// rnote `PressureCurve` exponent applied to raw pressure at CAPTURE, before
|
|
/// perfect_freehand. `2.0` = Pow2 (quadratic, fountain pen); `0.5` = Sqrt
|
|
/// (pencil); `1.0` = Linear (ballpoint / highlighter). Fed through the
|
|
/// existing `PressureCurve(gamma: …)` — no new pow function (spec §1).
|
|
final double pressureGamma;
|
|
|
|
/// perfect_freehand `thinning`: how strongly (pre-warped) pressure modulates
|
|
/// width. `0.0` = constant width (highlighter); high = wide dynamic range
|
|
/// (fountain pen) (spec §4).
|
|
final double pfThinning;
|
|
|
|
/// perfect_freehand `streamline`: EMA low-pass on input positions (spec §4).
|
|
final double pfStreamline;
|
|
|
|
/// perfect_freehand `smoothing`: outline corner-softening (spec §4).
|
|
final double pfSmoothing;
|
|
|
|
/// perfect_freehand `simulatePressure`: when true, fakes pressure from
|
|
/// velocity. All four presets ship `false` so REAL stylus pressure (already
|
|
/// pre-warped by [pressureGamma]) drives width (spec §4). The render path
|
|
/// still falls back to simulation when the device reports NO usable pressure.
|
|
final bool simulatePressure;
|
|
|
|
/// Round cap on the start of the stroke (false = square end, highlighter).
|
|
final bool capStart;
|
|
|
|
/// Round cap on the end of the stroke (false = square end, highlighter).
|
|
final bool capEnd;
|
|
|
|
/// Whether the ends taper to a point (fountain pen) (spec §4).
|
|
final bool taper;
|
|
|
|
/// Per-stroke opacity in [0,1]; `1.0` = solid. For fountain pen / highlighter
|
|
/// this flat value is used; ballpoint/pencil derive opacity from pressure
|
|
/// instead (spec §3/§4) — see [resolveStrokeOpacity]. Applied by the painters
|
|
/// via [resolveStrokePaint] (multiplied into the stroke color's alpha).
|
|
final double opacity;
|
|
|
|
/// Whether the brush composites with [BlendMode.multiply] (highlighter
|
|
/// build-up / marker feel). Applied by [resolveStrokePaint].
|
|
final bool blendMultiply;
|
|
}
|
|
|
|
/// The 4 brush presets, populated from the spec §4 tables.
|
|
///
|
|
/// Widths are the spec's logical-px diameters re-expressed as page-width
|
|
/// fractions against the project's ~1000px logical page (the existing
|
|
/// pen/highlighter widths are 0.006 / 0.02). Fountain pen ≈ pen (0.006),
|
|
/// highlighter ≈ 0.02 so the existing pen/highlighter visuals are PRESERVED as
|
|
/// the fountainPen/highlighter presets (no regression).
|
|
const Map<BrushKind, BrushProfile> kBrushPresets = {
|
|
// Fountain pen — spec §4: size~6, thinning 0.9, smoothing 0.55,
|
|
// streamline 0.45, simulatePressure false, taper on, pressure pre-warped to
|
|
// p² (Pow2 / quadratic = pressureGamma 2.0). Solid ink (opacity 1.0).
|
|
BrushKind.fountainPen: BrushProfile(
|
|
kind: BrushKind.fountainPen,
|
|
baseWidthFraction: 0.006,
|
|
pressureGamma: 2.0,
|
|
pfThinning: 0.9,
|
|
pfStreamline: 0.45,
|
|
pfSmoothing: 0.55,
|
|
simulatePressure: false,
|
|
capStart: true,
|
|
capEnd: true,
|
|
taper: true,
|
|
opacity: 1.0,
|
|
blendMultiply: false,
|
|
),
|
|
// Ballpoint — spec §4: size~2.2, thinning 0.15, smoothing 0.5,
|
|
// streamline 0.55, near-constant width, linear pressure (gamma 1.0). The
|
|
// "tell" is pressure → OPACITY (0.55 + 0.45·pressureAvg, resolved per-stroke
|
|
// in resolveStrokeOpacity); the flat opacity field below is the solid cap.
|
|
BrushKind.ballpoint: BrushProfile(
|
|
kind: BrushKind.ballpoint,
|
|
baseWidthFraction: 0.0022,
|
|
pressureGamma: 1.0,
|
|
pfThinning: 0.15,
|
|
pfStreamline: 0.55,
|
|
pfSmoothing: 0.5,
|
|
simulatePressure: false,
|
|
capStart: true,
|
|
capEnd: true,
|
|
taper: false,
|
|
opacity: 1.0,
|
|
blendMultiply: false,
|
|
),
|
|
// Highlighter — spec §4: size~22, thinning 0.0 (constant width),
|
|
// smoothing 0.4, streamline 0.5, square (uncapped) ends, translucent +
|
|
// multiply build-up. opacity 0.35 / blendMultiply true are APPLIED via
|
|
// resolveStrokePaint: the 0.35 is multiplied INTO the color's existing alpha
|
|
// (the capture path ships a 0x80 / 50% translucent color), and the stroke
|
|
// composites with BlendMode.multiply (cross-stroke overlap darkens = marker).
|
|
BrushKind.highlighter: BrushProfile(
|
|
kind: BrushKind.highlighter,
|
|
baseWidthFraction: 0.02,
|
|
pressureGamma: 1.0,
|
|
pfThinning: 0.0,
|
|
pfStreamline: 0.5,
|
|
pfSmoothing: 0.4,
|
|
simulatePressure: false,
|
|
capStart: false,
|
|
capEnd: false,
|
|
taper: false,
|
|
opacity: 0.35,
|
|
blendMultiply: true,
|
|
),
|
|
// Pencil — spec §4: size~3, thinning 0.5, smoothing 0.5, streamline 0.4,
|
|
// pressure pre-warped to √p (Sqrt = pressureGamma 0.5). Pressure → OPACITY
|
|
// (0.35 + 0.55·pressureAvg, resolveStrokeOpacity) makes it lighter/scratchy;
|
|
// the 0.9 field is the solid cap. TODO(brush-texture): paper grain deferred.
|
|
BrushKind.pencil: BrushProfile(
|
|
kind: BrushKind.pencil,
|
|
baseWidthFraction: 0.003,
|
|
pressureGamma: 0.5,
|
|
pfThinning: 0.5,
|
|
pfStreamline: 0.4,
|
|
pfSmoothing: 0.5,
|
|
simulatePressure: false,
|
|
capStart: true,
|
|
capEnd: true,
|
|
taper: false,
|
|
opacity: 0.9,
|
|
blendMultiply: false,
|
|
),
|
|
};
|
|
|
|
/// Resolve the [BrushProfile] for [kind] (always present; const map).
|
|
BrushProfile brushProfileFor(BrushKind kind) => kBrushPresets[kind]!;
|
|
|
|
// ---- Compositing (opacity + blend) — closes TODO(brush-opacity) -------------
|
|
//
|
|
// perfect_freehand produces a single closed fill polygon per stroke; the
|
|
// painters then fill it with ONE Paint. These helpers resolve that Paint's
|
|
// alpha + blend mode from the stroke's [BrushProfile] so the four brushes feel
|
|
// distinct (the ballpoint/highlighter/pencil "soul"), while geometry stays in
|
|
// the freehand path. Both render paths (PenCanvas + the PDF
|
|
// `_PageOverlayPainter`) call [resolveStrokePaint] so they can never diverge.
|
|
|
|
/// Resolve the EFFECTIVE per-stroke opacity in [0,1] for [profile], given the
|
|
/// stroke's AVERAGE pressure [pressureAvg] (already gamma-pre-warped at
|
|
/// capture, but for opacity we want the raw feel of "how hard you pressed", so
|
|
/// callers pass the mean of each point's `pressure ?? 0.5`).
|
|
///
|
|
/// PER-STROKE (not per-segment): one alpha for the whole stroke this increment.
|
|
/// The spec (§3/§4) ties ballpoint/pencil opacity to pressure; fountain pen and
|
|
/// highlighter use the profile's flat [BrushProfile.opacity]. Per-point opacity
|
|
/// (splitting into pressure-banded sub-strokes — spec §4) is deferred.
|
|
double resolveStrokeOpacity(BrushProfile profile, {double pressureAvg = 0.5}) {
|
|
final p = pressureAvg.clamp(0.0, 1.0);
|
|
switch (profile.kind) {
|
|
// Spec §4: ballpoint "tell" is pressure → opacity (near-constant width).
|
|
case BrushKind.ballpoint:
|
|
return (0.55 + 0.45 * p).clamp(0.0, 1.0);
|
|
// Spec §4: pencil darkens with pressure (firm, quick-darkening √p feel).
|
|
case BrushKind.pencil:
|
|
return (0.35 + 0.55 * p).clamp(0.0, 1.0);
|
|
// Fountain pen (solid 1.0) + highlighter (flat 0.35) use the profile value.
|
|
case BrushKind.fountainPen:
|
|
case BrushKind.highlighter:
|
|
return profile.opacity.clamp(0.0, 1.0);
|
|
}
|
|
}
|
|
|
|
/// Multiply [opacity] (0..1) into [argb]'s existing alpha channel and return the
|
|
/// new ARGB int. Keeps any alpha the capture path already baked in (e.g. the
|
|
/// highlighter's 0x80 translucent capture) so this composes WITHOUT
|
|
/// double-counting — the profile opacity scales whatever alpha the color has.
|
|
int applyOpacityToArgb(int argb, double opacity) {
|
|
final baseAlpha = (argb >> 24) & 0xFF;
|
|
final scaled = (baseAlpha * opacity.clamp(0.0, 1.0)).round().clamp(0, 255);
|
|
return (scaled << 24) | (argb & 0x00FFFFFF);
|
|
}
|
|
|
|
/// The fully-resolved fill [Color] + [BlendMode] for one stroke, so every
|
|
/// painter can configure its `Paint` identically. [argb] is the stroke's stored
|
|
/// color; [pressureAvg] is the mean point pressure (`pressure ?? 0.5`).
|
|
///
|
|
/// - [color]: stroke color with `profile`-resolved opacity multiplied into its
|
|
/// alpha (pressure-tied for ballpoint/pencil; flat for fountain/highlighter).
|
|
/// - [blendMode]: [BlendMode.multiply] for the highlighter (marker build-up:
|
|
/// cross-stroke overlap darkens), [BlendMode.srcOver] otherwise. The stroke
|
|
/// is still drawn ONCE per render (single fill polygon) so its OWN self-
|
|
/// overlap never darkens — that single-draw invariant lives in the painters.
|
|
class ResolvedStrokePaint {
|
|
const ResolvedStrokePaint({required this.color, required this.blendMode});
|
|
|
|
final Color color;
|
|
final BlendMode blendMode;
|
|
}
|
|
|
|
/// Resolve the paint config for a stroke drawn with [kind]. See
|
|
/// [ResolvedStrokePaint]. TODO(brush-texture): pencil paper-grain texture is
|
|
/// still deferred — opacity is enough for this increment.
|
|
ResolvedStrokePaint resolveStrokePaint(
|
|
BrushKind kind,
|
|
int argb, {
|
|
double pressureAvg = 0.5,
|
|
}) {
|
|
final profile = brushProfileFor(kind);
|
|
final opacity = resolveStrokeOpacity(profile, pressureAvg: pressureAvg);
|
|
return ResolvedStrokePaint(
|
|
color: Color(applyOpacityToArgb(argb, opacity)),
|
|
blendMode: profile.blendMultiply ? BlendMode.multiply : BlendMode.srcOver,
|
|
);
|
|
}
|