Skip to main content

motion_content/vector/
ink.rs

1//! Deterministic ink materials and pressure-sensitive drawing.
2
3use std::io;
4
5mod brush;
6mod lettering;
7
8pub use lettering::StrokeFont;
9
10pub use brush::{
11    BrushCap, BrushPreset, BrushStroke, BrushTiming, InkFill, InputSample, NibMode, Paper,
12    PigmentMix, PigmentPainting,
13};
14
15type Point = (f64, f64);
16
17/// A deterministic ink material for strokes and even-odd path fills.
18///
19/// [`BrushStroke::ink`] uses all effects. [`InkFill`] also uses
20/// bounded wash, rough edges and pooling; only directional fibers are stroke-only.
21///
22/// The default is solid ink with seed zero and no dry gaps, feathering, roughness,
23/// granulation, directional fibers, paper imprint, or edge pooling. Parameters
24/// use layer-local coordinates (except canvas-anchored paper) and are evaluated from
25/// geometry, not previous frames. This is a bounded procedural wash, not a fluid
26/// or paper-fiber simulation. Both rendering backends share the CPU coverage.
27///
28/// Builders store values; invalid settings are reported during rendering. Use a
29/// stable seed and vary bleed or pigment settings explicitly with frame-local time.
30///
31/// # Example
32///
33/// ```
34/// use motion_content::prelude::*;
35/// use motion_core::prelude::*;
36/// use motion_core::{
37///     DisplayList, RasterLimits, ResourcePool, ValidatedDisplayList, MAX_PATH_VERBS,
38///     MAX_STROKE_VERBS, MAX_SVG_PATH_BYTES, sample_offsets,
39/// };
40/// use motion_render::{Composition, RenderPrepare, RenderOptions, Renderer, render_still};
41/// use motion_content::vector::ink::*;
42/// # fn main() -> motion_core::Result {
43/// let brush = Ink::new(17)
44///     .dryness(0.25)
45///     .fibers(1.0)
46///     .bleed(4.0)
47///     .edge_roughness(1.5)
48///     .granulation(0.4)
49///     .paper(211, 0.45, 1.5)
50///     .edge_pooling(0.6, 2.0);
51/// let path = Path2D::parse_svg("M0 0L60 0")?;
52/// let stroke = BrushStroke::new(path, 24.0, Color::from_hex(0x23272c))
53///     .pressure(0.15, 1.2, 0.05)
54///     .ink_load(1.0, 0.25)
55///     .ink(brush);
56/// # let _ = stroke;
57/// # Ok(())
58/// # }
59/// ```
60#[derive(Clone, Copy, Debug, Default)]
61pub struct Ink {
62    pub(super) seed: u32,
63    pub(super) dryness: f32,
64    pub(super) bleed: f32,
65    pub(super) roughness: f32,
66    pub(super) granulation: f32,
67    fibers: f32,
68    paper: Option<(u32, f32, f32)>,
69    pooling: Option<(f32, f32)>,
70}
71
72impl Ink {
73    /// Creates solid ink with a fixed seed and all effects initially disabled.
74    pub fn new(seed: u32) -> Self {
75        Self {
76            seed,
77            ..Self::default()
78        }
79    }
80
81    /// Sets dry-brush strength in `0..=1`; one permits fully dry gaps.
82    /// Zero retains solid coverage unless other material effects are enabled.
83    pub fn dryness(mut self, strength: f32) -> Self {
84        self.dryness = strength;
85        self
86    }
87
88    /// Sets the maximum soft wash extent beyond a fill or stroke edge, in local pixels.
89    /// Must be finite and nonnegative. Zero disables feathering. The halo has
90    /// finite support, is affected by the layer transform, and respects clips.
91    /// Animate this value to suggest absorption without retaining frame history.
92    pub fn bleed(mut self, radius: f32) -> Self {
93        self.bleed = radius;
94        self
95    }
96
97    /// Sets the maximum inward/outward edge displacement in local pixels.
98    /// Must be finite and nonnegative. Seeded low-frequency noise shapes the edge;
99    /// zero preserves the geometric silhouette. It is not a blur radius.
100    pub fn edge_roughness(mut self, amplitude: f32) -> Self {
101        self.roughness = amplitude;
102        self
103    }
104
105    /// Sets pigment variation in `0..=1`, from uniform to strongly mottled.
106    /// This attenuates coverage, not RGB channels or the composition background.
107    /// Coarse local noise produces density variation without per-frame randomness.
108    pub fn granulation(mut self, strength: f32) -> Self {
109        self.granulation = strength;
110        self
111    }
112
113    /// Blends axis-oriented dry gaps towards path-following fibers in `0..=1`.
114    /// One uses full-path arc length and signed distance across the sampled
115    /// flattened segment. Requires dryness or depleted [`BrushStroke::ink_load`]
116    /// to expose gaps. Trim preserves the full-path phase. Changing geometry can
117    /// change arc length; corners, closed seams, and crossings are not simulated
118    /// bristle transport. Zero retains the original local-axis texture.
119    pub fn fibers(mut self, strength: f32) -> Self {
120        self.fibers = strength;
121        self
122    }
123
124    /// Imprints seeded paper fibers into pigment coverage, not the background.
125    /// Strength must be in `0..=1`, scale finite and positive in output pixels.
126    /// Paper is sampled in canvas coordinates, so moving/scaling a layer does not
127    /// move the paper. Reuse seed and scale on fills and strokes for one sheet.
128    /// This attenuates alpha only; it is not a paper height or fluid simulation.
129    pub fn paper(mut self, seed: u32, strength: f32, scale: f32) -> Self {
130        self.paper = Some((seed, strength, scale));
131        self
132    }
133
134    /// Adds an inner edge deposit by diluting the body relative to the rim.
135    /// Strength must be in `0..=1`; width must be finite and positive local pixels.
136    /// Applied to the fill boundary or stroke union, not individual segment caps. Does not
137    /// expand bounds, change RGB, fill completely dry gaps, or simulate drying.
138    pub fn edge_pooling(mut self, strength: f32, width: f32) -> Self {
139        self.pooling = Some((strength, width));
140        self
141    }
142
143    pub(crate) fn padding(self) -> io::Result<f32> {
144        let padding = self.bleed + self.roughness;
145        if ![
146            self.dryness,
147            self.bleed,
148            self.roughness,
149            self.granulation,
150            self.fibers,
151            padding,
152        ]
153        .iter()
154        .all(|value| value.is_finite())
155            || !(0.0..=1.0).contains(&self.dryness)
156            || !(0.0..=1.0).contains(&self.granulation)
157            || !(0.0..=1.0).contains(&self.fibers)
158            || self.paper.is_some_and(|(_, strength, scale)| {
159                !strength.is_finite()
160                    || !(0.0..=1.0).contains(&strength)
161                    || !scale.is_finite()
162                    || scale <= 0.0
163            })
164            || self.pooling.is_some_and(|(strength, width)| {
165                !strength.is_finite()
166                    || !(0.0..=1.0).contains(&strength)
167                    || !width.is_finite()
168                    || width <= 0.0
169            })
170            || self.bleed < 0.0
171            || self.roughness < 0.0
172        {
173            return Err(invalid(
174                "Ink strengths must be in 0..=1, bleed/roughness nonnegative, and paper/pooling scales positive and finite",
175            ));
176        }
177        Ok(padding)
178    }
179
180    #[cfg(test)]
181    pub(super) fn has_fill_edges(self) -> bool {
182        self.bleed > 0.0
183            || self.roughness > 0.0
184            || self.pooling.is_some_and(|(strength, _)| strength > 0.0)
185    }
186
187    pub(super) fn sample(self, point: Point, canvas: Point) -> InkSample {
188        let dry = (self.dryness > 0.0).then(|| DrySample::new(point, self.seed));
189        let mut density = dry.map_or(1.0, |dry| dry.coverage(f64::from(self.dryness)));
190        let mut pigment = 1.0;
191        if self.granulation > 0.0 {
192            let cloud = noise((point.0 * 0.08, point.1 * 0.08), self.seed.wrapping_add(43));
193            let grain = noise((point.0 * 0.65, point.1 * 0.65), self.seed.wrapping_add(71));
194            let factor =
195                1.0 - f64::from(self.granulation) * (0.2 + 0.8 * (cloud * 0.7 + grain * 0.3));
196            density *= factor;
197            pigment *= factor;
198        }
199        if let Some((seed, strength, scale)) = self.paper {
200            let paper = (canvas.0 / f64::from(scale), canvas.1 / f64::from(scale));
201            let horizontal = noise((paper.0 * 0.08, paper.1 * 2.8), seed);
202            let vertical = noise((paper.0 * 2.5, paper.1 * 0.12), seed.wrapping_add(101));
203            let grain = noise((paper.0 * 0.7, paper.1 * 0.7), seed.wrapping_add(137));
204            let factor = 1.0
205                - f64::from(strength)
206                    * (0.15 + 0.85 * (horizontal * 0.5 + vertical * 0.3 + grain * 0.2));
207            density *= factor;
208            pigment *= factor;
209        }
210        InkSample {
211            edge: if self.roughness > 0.0 {
212                f64::from(self.roughness)
213                    * (noise((point.0 * 0.22, point.1 * 0.22), self.seed.wrapping_add(29)) * 2.0
214                        - 1.0)
215            } else {
216                0.0
217            },
218            bleed: if self.bleed > 0.0 {
219                f64::from(self.bleed)
220                    * (0.65
221                        + 0.35
222                            * noise((point.0 * 0.35, point.1 * 0.35), self.seed.wrapping_add(61)))
223            } else {
224                0.0
225            },
226            density,
227            pigment,
228            point,
229            ink: Some(self),
230            dry,
231        }
232    }
233}
234
235#[derive(Clone, Copy)]
236pub(super) struct InkSample {
237    edge: f64,
238    bleed: f64,
239    density: f64,
240    pigment: f64,
241    point: Point,
242    ink: Option<Ink>,
243    dry: Option<DrySample>,
244}
245
246impl Default for InkSample {
247    fn default() -> Self {
248        Self {
249            edge: 0.0,
250            bleed: 0.0,
251            density: 1.0,
252            pigment: 1.0,
253            point: (0.0, 0.0),
254            ink: None,
255            dry: None,
256        }
257    }
258}
259
260impl InkSample {
261    #[cfg(test)]
262    pub(super) fn fill_coverage(self, coverage: f64) -> f64 {
263        coverage * self.density
264    }
265
266    pub(super) fn fill_edge_coverage(self, distance: f64, metric: f64) -> f64 {
267        self.coverage(distance, metric) * (self.density * self.pool_density(distance))
268    }
269
270    fn pool_density(self, distance: f64) -> f64 {
271        self.ink
272            .and_then(|ink| ink.pooling)
273            .map_or(1.0, |(strength, width)| {
274                let rim = (1.0 - (distance - self.edge).abs() / f64::from(width)).clamp(0.0, 1.0);
275                1.0 - f64::from(strength) * 0.45 * (1.0 - rim * rim)
276            })
277    }
278
279    pub(super) fn coverage(self, distance: f64, metric: f64) -> f64 {
280        let distance = distance - self.edge;
281        let core = (0.5 - distance / metric).clamp(0.0, 1.0);
282        // A finite-support wash leaves no tails beyond the validated local padding.
283        let halo = if self.bleed > 0.0 {
284            let remaining = (1.0 - distance.max(0.0) / self.bleed).clamp(0.0, 1.0);
285            remaining * remaining * 0.35
286        } else {
287            0.0
288        };
289        core + (1.0 - core) * halo
290    }
291
292    pub(super) fn shade(self, coverage: f64, distance: f64, fiber: Point, load: f64) -> f64 {
293        if load <= 0.0 {
294            return 0.0;
295        }
296        let mut density = self.density;
297        if let Some(ink) = self.ink {
298            if ink.fibers > 0.0 || load < 1.0 {
299                let dryness = 1.0 - (1.0 - f64::from(ink.dryness)) * load;
300                let isotropic = self
301                    .dry
302                    .unwrap_or_else(|| DrySample::new(self.point, ink.seed))
303                    .coverage(dryness);
304                let dry = if ink.fibers > 0.0 {
305                    let directional = dry_coverage(fiber, ink.seed, dryness);
306                    isotropic + (directional - isotropic) * f64::from(ink.fibers)
307                } else {
308                    isotropic
309                };
310                density = dry * self.pigment;
311            }
312            density *= self.pool_density(distance);
313        }
314        coverage * density * load
315    }
316}
317
318fn invalid(message: &str) -> io::Error {
319    io::Error::new(io::ErrorKind::InvalidInput, message)
320}
321
322fn dry_coverage(point: Point, seed: u32, dryness: f64) -> f64 {
323    DrySample::new(point, seed).coverage(dryness)
324}
325
326#[derive(Clone, Copy)]
327struct DrySample {
328    grain: f64,
329    fiber: f64,
330}
331
332impl DrySample {
333    fn new(point: Point, seed: u32) -> Self {
334        Self {
335            grain: noise((point.0 * 1.3, point.1 * 1.3), seed),
336            fiber: noise((point.0 * 0.15, point.1 * 3.0), seed.wrapping_add(17)),
337        }
338    }
339
340    fn coverage(self, dryness: f64) -> f64 {
341        let density =
342            ((self.fiber - dryness * 0.7) * 8.0).clamp(0.0, 1.0) * (0.5 + 0.5 * self.grain);
343        1.0 - dryness + dryness * density
344    }
345}
346
347/// Lattice value noise in 0..=1, stateless so effects stay attached to the path.
348fn noise(point: Point, seed: u32) -> f64 {
349    motion_core::noise::value2([point.0, point.1], seed) * 0.5 + 0.5
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    #[test]
357    fn fill_edges_have_bounded_wash_and_relative_rim_density() {
358        let point = (13.5, 24.5);
359        let ink = Ink::new(17).bleed(4.0).edge_pooling(1.0, 2.0);
360        assert!(ink.has_fill_edges());
361        assert!(
362            !Ink::new(17)
363                .fibers(1.0)
364                .edge_pooling(0.0, 2.0)
365                .has_fill_edges()
366        );
367        let sample = ink.sample(point, point);
368        assert!(sample.fill_edge_coverage(1.0, 1.0) > 0.0);
369        assert_eq!(sample.fill_edge_coverage(5.0, 1.0), 0.0);
370        assert!(sample.fill_edge_coverage(-0.5, 1.0) > sample.fill_edge_coverage(-5.0, 1.0));
371        let fibers = ink.fibers(1.0).sample(point, point);
372        assert_eq!(
373            sample.fill_edge_coverage(-0.5, 1.0),
374            fibers.fill_edge_coverage(-0.5, 1.0)
375        );
376    }
377
378    #[test]
379    fn fill_density_preserves_coverage_and_is_stateless() {
380        let point = (13.5, 24.5);
381        let neutral = Ink::new(17).sample(point, point);
382        let ink = Ink::new(17)
383            .dryness(0.4)
384            .granulation(0.6)
385            .paper(211, 0.7, 1.5);
386        for coverage in [0.0, 0.25, 0.5, 0.75, 1.0] {
387            assert_eq!(neutral.fill_coverage(coverage), coverage);
388            let first = ink.sample(point, point).fill_coverage(coverage);
389            let _ = ink.sample((40.5, 8.5), (8.5, 40.5));
390            assert_eq!(first, ink.sample(point, point).fill_coverage(coverage));
391            assert!(first >= 0.0 && first <= coverage);
392        }
393        assert!(ink.sample(point, point).fill_coverage(1.0) < 1.0);
394        let paper = Ink::new(17).paper(211, 0.7, 1.5);
395        assert_eq!(
396            paper.sample(point, point).fill_coverage(1.0),
397            paper.sample((90.0, 12.0), point).fill_coverage(1.0)
398        );
399    }
400
401    #[test]
402    fn neutral_fibers_preserve_reference_dry_coverage() {
403        for point in [(2.5, 3.5), (15.5, 9.5), (37.5, 24.5)] {
404            let material = Ink::new(17).dryness(0.7).sample(point, point);
405            for distance in [-4.0, -0.2, 0.3, 2.0] {
406                let expected = (0.5_f64 - distance).clamp(0.0, 1.0)
407                    * dry_coverage(point, 17, f64::from(0.7_f32));
408                assert_eq!(
409                    material.shade(material.coverage(distance, 1.0), distance, (0.0, 0.0), 1.0),
410                    expected
411                );
412            }
413        }
414    }
415
416    #[test]
417    fn paper_is_canvas_anchored_and_pooling_keeps_a_darker_rim() {
418        let paper = Ink::new(17).paper(211, 0.7, 1.5);
419        let canvas = (13.5, 24.5);
420        assert_eq!(
421            paper.sample((0.0, 0.0), canvas).density,
422            paper.sample((90.0, 12.0), canvas).density
423        );
424        assert_eq!(
425            paper.sample((0.0, 0.0), canvas).density,
426            Ink::new(99)
427                .paper(211, 0.7, 1.5)
428                .sample((0.0, 0.0), canvas)
429                .density
430        );
431        assert!((1..20).any(|offset| {
432            paper
433                .sample((0.0, 0.0), (canvas.0 + f64::from(offset), canvas.1))
434                .density
435                != paper.sample((0.0, 0.0), canvas).density
436        }));
437        let pooled = Ink::new(17).edge_pooling(1.0, 2.0).sample(canvas, canvas);
438        assert!(
439            pooled.shade(1.0, -0.5, (0.0, 0.0), 1.0) > pooled.shade(1.0, -5.0, (0.0, 0.0), 1.0)
440        );
441        assert_eq!(pooled.shade(1.0, -0.5, (0.0, 0.0), 0.0), 0.0);
442    }
443}