Skip to main content

motion_content/vector/
ink.rs

1//! Deterministic ink materials and pressure-sensitive drawing.
2
3use motion_core::Transform;
4use std::io;
5
6mod brush;
7mod lettering;
8mod paint;
9mod paper;
10
11pub use lettering::StrokeFont;
12
13pub use brush::{
14    BrushCap, BrushPreset, BrushStroke, BrushStrokeBuilder, BrushTiming, InkFill, InputSample,
15    NibMode, PigmentMix, PigmentPainting,
16};
17pub use paper::Paper;
18
19use paper::PaperSampler;
20
21type Point = (f64, f64);
22
23/// A deterministic ink material for strokes and even-odd path fills.
24///
25/// [`BrushStrokeBuilder::ink`] uses all effects. [`InkFill`] uses dryness, bleed, roughness,
26/// granulation, paper, edge pooling and [`edge_darkening`](Self::edge_darkening). Directional
27/// [`fibers`](Self::fibers), [`build_up`](Self::build_up),
28/// [`edge_anisotropy`](Self::edge_anisotropy) and [`height_map`](Self::height_map) need stroke
29/// geometry (arc length, self-overlap or a nib) and are ignored by fills.
30///
31/// The default is solid ink with seed zero and no dry gaps, feathering, roughness,
32/// granulation, directional fibers, paper imprint, or edge pooling. Parameters
33/// use layer-local coordinates (except canvas-anchored paper) and are evaluated from
34/// geometry, not previous frames. This is a bounded procedural wash, not a fluid
35/// or paper-fiber simulation. Both rendering backends share the CPU coverage.
36///
37/// Builders store values; invalid settings are reported when a stroke is built or rendered. Use a
38/// stable seed and vary bleed or pigment settings explicitly with frame-local time.
39///
40/// # Example
41///
42/// ```
43/// use motion_content::prelude::*;
44/// use motion_core::prelude::*;
45/// use motion_core::{
46///     DisplayList, RasterLimits, ResourceStore, ValidatedDisplayList, MAX_PATH_VERBS,
47///     MAX_STROKE_VERBS, MAX_SVG_PATH_BYTES, sample_offsets,
48/// };
49/// use motion_render::{Composition, RenderPrepare, RenderOptions, BackendKind, render_still};
50/// use motion_content::vector::ink::*;
51/// # fn main() -> motion_core::Result {
52/// let brush = Ink::new(17)
53///     .dryness(0.25)
54///     .fibers(1.0)
55///     .bleed(4.0)
56///     .edge_roughness(1.5)
57///     .granulation(0.4)
58///     .paper(Paper::Procedural { seed: 211, strength: 0.45, scale: 1.5 })
59///     .edge_pooling(0.6, 2.0);
60/// let path = Path2D::parse_svg("M0 0L60 0")?;
61/// let stroke = BrushStroke::builder(path, 24.0, Color::from_hex(0x23272c))
62///     .pressure(0.15, 1.2, 0.05)
63///     .ink_load(1.0, 0.25)
64///     .ink(brush)
65///     .build()?;
66/// # let _ = stroke;
67/// # Ok(())
68/// # }
69/// ```
70#[derive(Clone, Debug)]
71pub struct Ink {
72    pub(super) seed: u32,
73    pub(super) dryness: f32,
74    pub(super) bleed: f32,
75    pub(super) roughness: f32,
76    pub(super) granulation: f32,
77    fibers: f32,
78    paper: Option<Paper>,
79    paper_transform: Transform,
80    pooling: Option<(f32, f32)>,
81    pub(super) build_up: f64,
82    pub(super) edge_darkening: f64,
83    pub(super) anisotropy: f64,
84    pub(super) height: f64,
85}
86
87impl Default for Ink {
88    fn default() -> Self {
89        Self {
90            seed: 0,
91            dryness: 0.0,
92            bleed: 0.0,
93            roughness: 0.0,
94            granulation: 0.0,
95            fibers: 0.0,
96            paper: None,
97            paper_transform: Transform::IDENTITY,
98            pooling: None,
99            build_up: 0.0,
100            edge_darkening: 0.0,
101            anisotropy: 1.0,
102            height: 0.0,
103        }
104    }
105}
106
107impl Ink {
108    /// Creates solid ink with a fixed seed and all effects initially disabled.
109    pub fn new(seed: u32) -> Self {
110        Self {
111            seed,
112            ..Self::default()
113        }
114    }
115
116    /// Sets dry-brush strength in `0..=1`; one permits fully dry gaps.
117    /// Zero retains solid coverage unless other material effects are enabled.
118    pub fn dryness(mut self, strength: f32) -> Self {
119        self.dryness = strength;
120        self
121    }
122
123    /// Sets the maximum soft wash extent beyond a fill or stroke edge, in local pixels.
124    /// Must be finite and nonnegative. Zero disables feathering. The halo has
125    /// finite support, is affected by the layer transform, and respects clips.
126    /// Animate this value to suggest absorption without retaining frame history.
127    pub fn bleed(mut self, radius: f32) -> Self {
128        self.bleed = radius;
129        self
130    }
131
132    /// Sets the maximum inward/outward edge displacement in local pixels.
133    /// Must be finite and nonnegative. Seeded low-frequency noise shapes the edge;
134    /// zero preserves the geometric silhouette. It is not a blur radius.
135    pub fn edge_roughness(mut self, amplitude: f32) -> Self {
136        self.roughness = amplitude;
137        self
138    }
139
140    /// Sets pigment variation in `0..=1`, from uniform to strongly mottled.
141    /// This attenuates coverage, not RGB channels or the composition background.
142    /// Coarse local noise produces density variation without per-frame randomness.
143    pub fn granulation(mut self, strength: f32) -> Self {
144        self.granulation = strength;
145        self
146    }
147
148    /// Blends axis-oriented dry gaps towards path-following fibers in `0..=1`.
149    /// One uses full-path arc length and signed distance across the sampled
150    /// flattened segment. Requires dryness or depleted [`BrushStrokeBuilder::ink_load`]
151    /// to expose gaps. Trim preserves the full-path phase. Changing geometry can
152    /// change arc length; corners, closed seams, and crossings are not simulated
153    /// bristle transport. Zero retains the original local-axis texture. Stroke-only.
154    pub fn fibers(mut self, strength: f32) -> Self {
155        self.fibers = strength;
156        self
157    }
158
159    /// Sets the paper sheet that attenuates pigment coverage, not the background.
160    ///
161    /// [`Paper::Procedural`] fibers are seeded noise (strength in `0..=1`, scale finite and
162    /// positive in paper-space pixels); [`Paper::Texture`] repeats a resolved height map and
163    /// attenuates by its luma. Either is sampled in paper space: local coordinates mapped by
164    /// [`paper_transform`](Self::paper_transform), which is the identity (untransformed design
165    /// space) by default. A layer that moves or scales keeps its paper fixed on the canvas only
166    /// when the transform equals the node transform. Reuse one `Paper` on fills and strokes for
167    /// one sheet. Setting a paper replaces the previous one. This attenuates alpha only; it is
168    /// not a paper height or fluid simulation.
169    pub fn paper(mut self, paper: Paper) -> Self {
170        self.paper = Some(paper);
171        self
172    }
173
174    /// Supplies the authored local-to-paper coordinates; the identity uses untransformed design
175    /// space. Must be finite.
176    pub fn paper_transform(mut self, transform: Transform) -> Self {
177        self.paper_transform = transform;
178        self
179    }
180
181    /// Adds an inner edge deposit by diluting the body relative to the rim.
182    /// Strength must be in `0..=1`; width must be finite and positive local pixels.
183    /// Applied to the fill boundary or stroke union, not individual segment caps. Does not
184    /// expand bounds, change RGB, fill completely dry gaps, or simulate drying.
185    pub fn edge_pooling(mut self, strength: f32, width: f32) -> Self {
186        self.pooling = Some((strength, width));
187        self
188    }
189
190    /// Darkens RGB near the geometric rim, in `0..=1`. Unlike pooling it changes color, not
191    /// alpha.
192    pub fn edge_darkening(mut self, strength: f64) -> Self {
193        self.edge_darkening = strength;
194        self
195    }
196
197    /// Adds density at stroke self-intersections using independent segment coverage, in
198    /// `0..=1`. Stroke-only.
199    pub fn build_up(mut self, strength: f64) -> Self {
200        self.build_up = strength;
201        self
202    }
203
204    /// Scales directional material coordinates; a positive ratio in `0.1..=10`. Stroke-only.
205    pub fn edge_anisotropy(mut self, ratio: f64) -> Self {
206        self.anisotropy = ratio;
207        self
208    }
209
210    /// Makes a stroke a grayscale rounded-ridge height drawable for
211    /// `Optics::Relight { height_from: Luma }`; strength in `0..=16`. Stroke-only.
212    pub fn height_map(mut self, strength: f64) -> Self {
213        self.height = strength;
214        self
215    }
216
217    pub(crate) fn padding(&self) -> io::Result<f32> {
218        let padding = self.bleed + self.roughness;
219        if ![
220            self.dryness,
221            self.bleed,
222            self.roughness,
223            self.granulation,
224            self.fibers,
225            padding,
226        ]
227        .iter()
228        .all(|value| value.is_finite())
229            || !(0.0..=1.0).contains(&self.dryness)
230            || !(0.0..=1.0).contains(&self.granulation)
231            || !(0.0..=1.0).contains(&self.fibers)
232            || self.paper.as_ref().is_some_and(|paper| !paper.is_valid())
233            || !self.paper_transform.is_finite()
234            || self.pooling.is_some_and(|(strength, width)| {
235                !strength.is_finite()
236                    || !(0.0..=1.0).contains(&strength)
237                    || !width.is_finite()
238                    || width <= 0.0
239            })
240            || self.bleed < 0.0
241            || self.roughness < 0.0
242            || !self.build_up.is_finite()
243            || !(0.0..=1.0).contains(&self.build_up)
244            || !self.edge_darkening.is_finite()
245            || !(0.0..=1.0).contains(&self.edge_darkening)
246            || !self.anisotropy.is_finite()
247            || !(0.1..=10.0).contains(&self.anisotropy)
248            || !self.height.is_finite()
249            || !(0.0..=16.0).contains(&self.height)
250        {
251            return Err(invalid(
252                "Ink strengths must be in 0..=1, bleed/roughness nonnegative, paper/pooling scales positive and finite, anisotropy in 0.1..=10 and height in 0..=16",
253            ));
254        }
255        Ok(padding)
256    }
257
258    /// Maps a local point into paper space.
259    fn paper_space(&self, point: motion_core::Point) -> motion_core::Point {
260        self.paper_transform.apply(point)
261    }
262
263    /// Prepares the texture paper, if any, once per baked material.
264    fn paper_sampler(&self) -> Option<PaperSampler> {
265        match &self.paper {
266            Some(Paper::Texture {
267                texture,
268                grain_scale,
269                absorbency,
270            }) => Some(PaperSampler::new(
271                texture.clone(),
272                *grain_scale,
273                *absorbency,
274            )),
275            _ => None,
276        }
277    }
278
279    #[cfg(test)]
280    pub(super) fn has_fill_edges(&self) -> bool {
281        self.bleed > 0.0
282            || self.roughness > 0.0
283            || self.pooling.is_some_and(|(strength, _)| strength > 0.0)
284    }
285
286    pub(super) fn sample(&self, point: Point, canvas: Point) -> InkSample {
287        let dry = (self.dryness > 0.0).then(|| DrySample::new(point, self.seed));
288        let mut density = dry.map_or(1.0, |dry| dry.coverage(f64::from(self.dryness)));
289        let mut pigment = 1.0;
290        if self.granulation > 0.0 {
291            let cloud = noise((point.0 * 0.08, point.1 * 0.08), self.seed.wrapping_add(43));
292            let grain = noise((point.0 * 0.65, point.1 * 0.65), self.seed.wrapping_add(71));
293            let factor =
294                1.0 - f64::from(self.granulation) * (0.2 + 0.8 * (cloud * 0.7 + grain * 0.3));
295            density *= factor;
296            pigment *= factor;
297        }
298        if let Some(Paper::Procedural {
299            seed,
300            strength,
301            scale,
302        }) = self.paper
303        {
304            let paper = (canvas.0 / f64::from(scale), canvas.1 / f64::from(scale));
305            let horizontal = noise((paper.0 * 0.08, paper.1 * 2.8), seed);
306            let vertical = noise((paper.0 * 2.5, paper.1 * 0.12), seed.wrapping_add(101));
307            let grain = noise((paper.0 * 0.7, paper.1 * 0.7), seed.wrapping_add(137));
308            let factor = 1.0
309                - f64::from(strength)
310                    * (0.15 + 0.85 * (horizontal * 0.5 + vertical * 0.3 + grain * 0.2));
311            density *= factor;
312            pigment *= factor;
313        }
314        InkSample {
315            edge: if self.roughness > 0.0 {
316                f64::from(self.roughness)
317                    * (noise((point.0 * 0.22, point.1 * 0.22), self.seed.wrapping_add(29)) * 2.0
318                        - 1.0)
319            } else {
320                0.0
321            },
322            bleed: if self.bleed > 0.0 {
323                f64::from(self.bleed)
324                    * (0.65
325                        + 0.35
326                            * noise((point.0 * 0.35, point.1 * 0.35), self.seed.wrapping_add(61)))
327            } else {
328                0.0
329            },
330            density,
331            pigment,
332            point,
333            shade: Some(Shade {
334                seed: self.seed,
335                dryness: self.dryness,
336                fibers: self.fibers,
337                pooling: self.pooling,
338            }),
339            dry,
340        }
341    }
342}
343
344#[derive(Clone, Copy)]
345pub(super) struct InkSample {
346    edge: f64,
347    bleed: f64,
348    density: f64,
349    pigment: f64,
350    point: Point,
351    shade: Option<Shade>,
352    dry: Option<DrySample>,
353}
354
355/// The per-material constants that [`InkSample`] needs after sampling.
356#[derive(Clone, Copy)]
357struct Shade {
358    seed: u32,
359    dryness: f32,
360    fibers: f32,
361    pooling: Option<(f32, f32)>,
362}
363
364impl Default for InkSample {
365    fn default() -> Self {
366        Self {
367            edge: 0.0,
368            bleed: 0.0,
369            density: 1.0,
370            pigment: 1.0,
371            point: (0.0, 0.0),
372            shade: None,
373            dry: None,
374        }
375    }
376}
377
378impl InkSample {
379    #[cfg(test)]
380    pub(super) fn fill_coverage(self, coverage: f64) -> f64 {
381        coverage * self.density
382    }
383
384    pub(super) fn fill_edge_coverage(self, distance: f64, metric: f64) -> f64 {
385        self.coverage(distance, metric) * (self.density * self.pool_density(distance))
386    }
387
388    fn pool_density(self, distance: f64) -> f64 {
389        self.shade
390            .and_then(|shade| shade.pooling)
391            .map_or(1.0, |(strength, width)| {
392                let rim = (1.0 - (distance - self.edge).abs() / f64::from(width)).clamp(0.0, 1.0);
393                1.0 - f64::from(strength) * 0.45 * (1.0 - rim * rim)
394            })
395    }
396
397    pub(super) fn coverage(self, distance: f64, metric: f64) -> f64 {
398        let distance = distance - self.edge;
399        let core = (0.5 - distance / metric).clamp(0.0, 1.0);
400        // A finite-support wash leaves no tails beyond the validated local padding.
401        let halo = if self.bleed > 0.0 {
402            let remaining = (1.0 - distance.max(0.0) / self.bleed).clamp(0.0, 1.0);
403            remaining * remaining * 0.35
404        } else {
405            0.0
406        };
407        core + (1.0 - core) * halo
408    }
409
410    pub(super) fn shade(self, coverage: f64, distance: f64, fiber: Point, load: f64) -> f64 {
411        if load <= 0.0 {
412            return 0.0;
413        }
414        let mut density = self.density;
415        if let Some(shade) = self.shade {
416            if shade.fibers > 0.0 || load < 1.0 {
417                let dryness = 1.0 - (1.0 - f64::from(shade.dryness)) * load;
418                let isotropic = self
419                    .dry
420                    .unwrap_or_else(|| DrySample::new(self.point, shade.seed))
421                    .coverage(dryness);
422                let dry = if shade.fibers > 0.0 {
423                    let directional = dry_coverage(fiber, shade.seed, dryness);
424                    isotropic + (directional - isotropic) * f64::from(shade.fibers)
425                } else {
426                    isotropic
427                };
428                density = dry * self.pigment;
429            }
430            density *= self.pool_density(distance);
431        }
432        coverage * density * load
433    }
434}
435
436fn invalid(message: &str) -> io::Error {
437    io::Error::new(io::ErrorKind::InvalidInput, message)
438}
439
440fn dry_coverage(point: Point, seed: u32, dryness: f64) -> f64 {
441    DrySample::new(point, seed).coverage(dryness)
442}
443
444#[derive(Clone, Copy)]
445struct DrySample {
446    grain: f64,
447    fiber: f64,
448}
449
450impl DrySample {
451    fn new(point: Point, seed: u32) -> Self {
452        Self {
453            grain: noise((point.0 * 1.3, point.1 * 1.3), seed),
454            fiber: noise((point.0 * 0.15, point.1 * 3.0), seed.wrapping_add(17)),
455        }
456    }
457
458    fn coverage(self, dryness: f64) -> f64 {
459        let density =
460            ((self.fiber - dryness * 0.7) * 8.0).clamp(0.0, 1.0) * (0.5 + 0.5 * self.grain);
461        1.0 - dryness + dryness * density
462    }
463}
464
465/// Lattice value noise in 0..=1, stateless so effects stay attached to the path.
466fn noise(point: Point, seed: u32) -> f64 {
467    motion_core::noise::value2([point.0, point.1], seed) * 0.5 + 0.5
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473
474    #[test]
475    fn fill_edges_have_bounded_wash_and_relative_rim_density() {
476        let point = (13.5, 24.5);
477        let ink = Ink::new(17).bleed(4.0).edge_pooling(1.0, 2.0);
478        assert!(ink.has_fill_edges());
479        assert!(
480            !Ink::new(17)
481                .fibers(1.0)
482                .edge_pooling(0.0, 2.0)
483                .has_fill_edges()
484        );
485        let sample = ink.sample(point, point);
486        assert!(sample.fill_edge_coverage(1.0, 1.0) > 0.0);
487        assert_eq!(sample.fill_edge_coverage(5.0, 1.0), 0.0);
488        assert!(sample.fill_edge_coverage(-0.5, 1.0) > sample.fill_edge_coverage(-5.0, 1.0));
489        let fibers = ink.fibers(1.0).sample(point, point);
490        assert_eq!(
491            sample.fill_edge_coverage(-0.5, 1.0),
492            fibers.fill_edge_coverage(-0.5, 1.0)
493        );
494    }
495
496    #[test]
497    fn fill_density_preserves_coverage_and_is_stateless() {
498        let point = (13.5, 24.5);
499        let neutral = Ink::new(17).sample(point, point);
500        let ink = Ink::new(17)
501            .dryness(0.4)
502            .granulation(0.6)
503            .paper(Paper::Procedural {
504                seed: 211,
505                strength: 0.7,
506                scale: 1.5,
507            });
508        for coverage in [0.0, 0.25, 0.5, 0.75, 1.0] {
509            assert_eq!(neutral.fill_coverage(coverage), coverage);
510            let first = ink.sample(point, point).fill_coverage(coverage);
511            let _ = ink.sample((40.5, 8.5), (8.5, 40.5));
512            assert_eq!(first, ink.sample(point, point).fill_coverage(coverage));
513            assert!(first >= 0.0 && first <= coverage);
514        }
515        assert!(ink.sample(point, point).fill_coverage(1.0) < 1.0);
516        let paper = Ink::new(17).paper(Paper::Procedural {
517            seed: 211,
518            strength: 0.7,
519            scale: 1.5,
520        });
521        assert_eq!(
522            paper.sample(point, point).fill_coverage(1.0),
523            paper.sample((90.0, 12.0), point).fill_coverage(1.0)
524        );
525    }
526
527    #[test]
528    fn neutral_fibers_preserve_reference_dry_coverage() {
529        for point in [(2.5, 3.5), (15.5, 9.5), (37.5, 24.5)] {
530            let material = Ink::new(17).dryness(0.7).sample(point, point);
531            for distance in [-4.0, -0.2, 0.3, 2.0] {
532                let expected = (0.5_f64 - distance).clamp(0.0, 1.0)
533                    * dry_coverage(point, 17, f64::from(0.7_f32));
534                assert_eq!(
535                    material.shade(material.coverage(distance, 1.0), distance, (0.0, 0.0), 1.0),
536                    expected
537                );
538            }
539        }
540    }
541
542    #[test]
543    fn paper_is_canvas_anchored_and_pooling_keeps_a_darker_rim() {
544        let paper = Ink::new(17).paper(Paper::Procedural {
545            seed: 211,
546            strength: 0.7,
547            scale: 1.5,
548        });
549        let canvas = (13.5, 24.5);
550        assert_eq!(
551            paper.sample((0.0, 0.0), canvas).density,
552            paper.sample((90.0, 12.0), canvas).density
553        );
554        assert_eq!(
555            paper.sample((0.0, 0.0), canvas).density,
556            Ink::new(99)
557                .paper(Paper::Procedural {
558                    seed: 211,
559                    strength: 0.7,
560                    scale: 1.5
561                })
562                .sample((0.0, 0.0), canvas)
563                .density
564        );
565        assert!((1..20).any(|offset| {
566            paper
567                .sample((0.0, 0.0), (canvas.0 + f64::from(offset), canvas.1))
568                .density
569                != paper.sample((0.0, 0.0), canvas).density
570        }));
571        let pooled = Ink::new(17).edge_pooling(1.0, 2.0).sample(canvas, canvas);
572        assert!(
573            pooled.shade(1.0, -0.5, (0.0, 0.0), 1.0) > pooled.shade(1.0, -5.0, (0.0, 0.0), 1.0)
574        );
575        assert_eq!(pooled.shade(1.0, -0.5, (0.0, 0.0), 0.0), 0.0);
576    }
577}