Skip to main content

motion_content/vector/ink/brush/advanced/
pigment.rs

1//! Pigment mixing and opt-in multi-stroke pigment compositions baked into one material.
2
3use super::super::{BrushStroke, Prepared, invalid};
4use super::bounds;
5use super::geometry::{outline, samples};
6use super::material::material;
7use crate::ImageAsset;
8use crate::vector::ink::paint::paint_sample;
9use motion_core::{
10    Canvas, Color, DrawContext, Drawable, FillRule, ImagePattern, Paint, Path2D, PathVerb, Point,
11    Rect, Transform, ValidationError,
12};
13use std::sync::{Arc, OnceLock};
14
15/// Optional color mixing approximation, separate from renderer blend modes.
16#[derive(Clone, Copy, Debug)]
17pub enum PigmentMix {
18    /// Linear-light interpolation.
19    Linear,
20    /// Six-band infinite-thickness Kubelka-Munk approximation, not calibrated pigments.
21    KubelkaMunk,
22}
23
24impl PigmentMix {
25    /// Mixes approximate reflectances; endpoints remain exact, and this is not a calibrated model.
26    pub fn mix(self, first: Color, second: Color, amount: f64) -> Result<Color, ValidationError> {
27        if !amount.is_finite() || !(0.0..=1.0).contains(&amount) {
28            return Err(invalid("pigment", "Require amount in 0..=1"));
29        }
30        if amount == 0.0 || first == second {
31            return Ok(first);
32        }
33        if amount == 1.0 {
34            return Ok(second);
35        }
36        let mut first = first.to_rgba_float();
37        let mut second = second.to_rgba_float();
38        for value in &mut first[..3] {
39            *value = motion_core::color::srgb_to_linear(*value);
40        }
41        for value in &mut second[..3] {
42            *value = motion_core::color::srgb_to_linear(*value);
43        }
44        let mut result = [0.0; 4];
45        let reflectance = |rgb: [f64; 4]| {
46            let [red, green, blue, _] = rgb.map(|value| value.clamp(0.0, 1.0));
47            let white = red.min(green).min(blue);
48            let red = red - white;
49            let green = green - white;
50            let blue = blue - white;
51            let (secondary, remaining, secondary_basis, primary_basis) =
52                if red <= green && red <= blue {
53                    if green <= blue {
54                        (
55                            green,
56                            blue - green,
57                            [1.0, 1.0, 1.0, 0.85, 0.1, 0.02],
58                            [0.9, 1.0, 0.6, 0.1, 0.01, 0.01],
59                        )
60                    } else {
61                        (
62                            blue,
63                            green - blue,
64                            [1.0, 1.0, 1.0, 0.85, 0.1, 0.02],
65                            [0.01, 0.12, 0.9, 1.0, 0.16, 0.02],
66                        )
67                    }
68                } else if green <= red && green <= blue {
69                    if red <= blue {
70                        (
71                            red,
72                            blue - red,
73                            [1.0, 0.7, 0.1, 0.1, 0.75, 1.0],
74                            [0.9, 1.0, 0.6, 0.1, 0.01, 0.01],
75                        )
76                    } else {
77                        (
78                            blue,
79                            red - blue,
80                            [1.0, 0.7, 0.1, 0.1, 0.75, 1.0],
81                            [0.02, 0.01, 0.02, 0.25, 0.8, 1.0],
82                        )
83                    }
84                } else if red <= green {
85                    (
86                        red,
87                        green - red,
88                        [0.03, 0.15, 0.95, 1.0, 1.0, 0.95],
89                        [0.01, 0.12, 0.9, 1.0, 0.16, 0.02],
90                    )
91                } else {
92                    (
93                        green,
94                        red - green,
95                        [0.03, 0.15, 0.95, 1.0, 1.0, 0.95],
96                        [0.02, 0.01, 0.02, 0.25, 0.8, 1.0],
97                    )
98                };
99            std::array::from_fn::<_, 6, _>(|index| {
100                (white + secondary * secondary_basis[index] + remaining * primary_basis[index])
101                    .clamp(1e-6, 1.0)
102            })
103        };
104        let spectra = if matches!(self, Self::KubelkaMunk) {
105            let first = reflectance(first);
106            let second = reflectance(second);
107            Some(std::array::from_fn::<_, 6, _>(|index| {
108                let ratio = |value: f64| (1.0 - value).powi(2) / (2.0 * value);
109                let absorption =
110                    ratio(first[index]) * (1.0 - amount) + ratio(second[index]) * amount;
111                1.0 / (1.0 + absorption + libm::sqrt(absorption * absorption + 2.0 * absorption))
112            }))
113        } else {
114            None
115        };
116        for channel in 0..3 {
117            result[channel] = match self {
118                Self::Linear => first[channel] * (1.0 - amount) + second[channel] * amount,
119                Self::KubelkaMunk => {
120                    let spectrum = spectra.as_ref().expect("Kubelka-Munk spectra");
121                    match channel {
122                        0 => spectrum[3] * 0.15 + spectrum[4] * 0.35 + spectrum[5] * 0.5,
123                        1 => {
124                            spectrum[1] * 0.1
125                                + spectrum[2] * 0.4
126                                + spectrum[3] * 0.4
127                                + spectrum[4] * 0.1
128                        }
129                        _ => spectrum[0] * 0.5 + spectrum[1] * 0.4 + spectrum[2] * 0.1,
130                    }
131                }
132            };
133        }
134        result[3] = first[3] * (1.0 - amount) + second[3] * amount;
135        let rgb = [result[0], result[1], result[2]].map(motion_core::color::linear_to_srgb);
136        Ok(Color::from_rgb_float(rgb[0], rgb[1], rgb[2]).with_alpha(result[3]))
137    }
138
139    /// Composes up to 32 strokes; only covered intersections use pigment mixing.
140    pub fn painting(self, strokes: Vec<BrushStroke>) -> PigmentPainting {
141        PigmentPainting {
142            strokes,
143            mix: self,
144            prepared: Arc::new(OnceLock::new()),
145        }
146    }
147}
148
149/// Immutable opt-in pigment composition, shared by CPU/GPU as one baked material.
150#[derive(Clone, Debug)]
151pub struct PigmentPainting {
152    strokes: Vec<BrushStroke>,
153    mix: PigmentMix,
154    prepared: Arc<OnceLock<Result<Prepared, ValidationError>>>,
155}
156
157impl Drawable for PigmentPainting {
158    fn bounds(&self, _: &DrawContext) -> Result<Rect, ValidationError> {
159        if !(1..=32).contains(&self.strokes.len()) {
160            return Err(invalid("pigment_strokes", "Require 1..=32 pigment strokes"));
161        }
162        let mut result = bounds(&self.strokes[0])?;
163        for stroke in &self.strokes[1..] {
164            result = result.union(bounds(stroke)?);
165        }
166        Ok(result)
167    }
168
169    fn draw(&self, context: &DrawContext, canvas: &mut Canvas<'_>) -> Result<(), ValidationError> {
170        let bounds = self.bounds(context)?;
171        let prepared = self
172            .prepared
173            .get_or_init(|| {
174                let width = bounds.width().ceil().max(1.0) as u32;
175                let height = bounds.height().ceil().max(1.0) as u32;
176                if width > 2048 || height > 2048 || u64::from(width) * u64::from(height) > 262144 {
177                    return Err(invalid(
178                        "pigment_pixels",
179                        "Pigment composition exceeds 262144 texels",
180                    ));
181                }
182                let mut layers = Vec::new();
183                let mut work = 0_u64;
184                for stroke in &self.strokes {
185                    let path = outline(&stroke.config, &samples(&stroke.config, stroke.progress)?)?;
186                    work = work.saturating_add(
187                        path.verbs().len() as u64 * u64::from(width) * u64::from(height),
188                    );
189                    if work > 32_000_000 {
190                        return Err(invalid(
191                            "pigment_work",
192                            "Pigment coverage exceeds evaluation allowance",
193                        ));
194                    }
195                    layers.push((path, material(&stroke.config, self::bounds(stroke)?)?));
196                }
197                let mut pixels = Vec::with_capacity(width as usize * height as usize);
198                for row in 0..height {
199                    for column in 0..width {
200                        let point = Point::new(
201                            bounds.left + f64::from(column) + 0.5,
202                            bounds.top + f64::from(row) + 0.5,
203                        );
204                        let mut color = Color::TRANSPARENT;
205                        for (path, tiles) in &layers {
206                            if !inside_outline(path, point) {
207                                continue;
208                            }
209                            let mut source = [0.0; 4];
210                            for tile in tiles {
211                                if let Paint::Pattern(pattern) = &tile.paint {
212                                    let sample = paint_sample(&tile.paint, point, tile.bounds)?;
213                                    if sample[3] > 0.0 {
214                                        source = sample;
215                                        break;
216                                    }
217                                    let _ = pattern;
218                                }
219                            }
220                            if source[3] <= 0.0 {
221                                continue;
222                            }
223                            let source = Color::from_rgb_float(source[0], source[1], source[2])
224                                .with_alpha(source[3]);
225                            let previous = color.to_rgba_float();
226                            let incoming = source.to_rgba_float();
227                            let alpha = incoming[3] + previous[3] * (1.0 - incoming[3]);
228                            let mixed = self.mix.mix(color, source, 0.5)?.to_rgba_float();
229                            let overlap = previous[3] * incoming[3];
230                            let mut output = [0.0; 3];
231                            for index in 0..3 {
232                                output[index] =
233                                    (previous[index] * previous[3] * (1.0 - incoming[3])
234                                        + incoming[index] * incoming[3] * (1.0 - previous[3])
235                                        + mixed[index] * overlap)
236                                        / alpha;
237                            }
238                            color = Color::from_rgb_float(output[0], output[1], output[2])
239                                .with_alpha(alpha);
240                        }
241                        pixels.push(color.to_rgba_float());
242                    }
243                }
244                let image = ImageAsset::from_rgba_float(width, height, pixels)
245                    .map_err(|error| invalid("pigment_image", &error.to_string()))?;
246                let paint = ImagePattern::from_asset(Arc::new(image))
247                    .transform(Transform::translate(bounds.left, bounds.top))
248                    .into();
249                let mut path = Path2D::new();
250                path.try_move_to(Point::new(bounds.left, bounds.top))?;
251                path.try_line_to(Point::new(bounds.right, bounds.top))?;
252                path.try_line_to(Point::new(bounds.right, bounds.bottom))?;
253                path.try_line_to(Point::new(bounds.left, bounds.bottom))?;
254                path.try_close()?;
255                Ok(Prepared {
256                    path: Arc::new(path),
257                    paint,
258                    bounds,
259                })
260            })
261            .as_ref()
262            .map_err(Clone::clone)?;
263        canvas.fill_path(
264            prepared.path.clone(),
265            Transform::IDENTITY,
266            prepared.paint.clone(),
267            FillRule::NonZero,
268        )
269    }
270}
271
272fn inside_outline(path: &Path2D, point: Point) -> bool {
273    let mut winding = 0_i32;
274    let mut first = Point::new(0.0, 0.0);
275    let mut previous = first;
276    for verb in path.verbs() {
277        let end = match verb {
278            PathVerb::MoveTo(position) => {
279                first = *position;
280                previous = first;
281                continue;
282            }
283            PathVerb::LineTo(position) => *position,
284            PathVerb::Close => first,
285            _ => continue,
286        };
287        let cross = (end.x - previous.x) * (point.y - previous.y)
288            - (point.x - previous.x) * (end.y - previous.y);
289        if previous.y <= point.y && end.y > point.y && cross > 0.0 {
290            winding += 1;
291        } else if previous.y > point.y && end.y <= point.y && cross < 0.0 {
292            winding -= 1;
293        }
294        previous = end;
295    }
296    winding != 0
297}