// 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 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, // Was 0.9 — too aggressive on short CJK strokes (width collapses mid-glyph). pfThinning: 0.65, pfStreamline: 0.4, pfSmoothing: 0.5, simulatePressure: false, capStart: true, capEnd: true, // Light taper only; full taper made Chinese characters look frayed. taper: false, 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, ); }