1use 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#[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 pub fn new(seed: u32) -> Self {
75 Self {
76 seed,
77 ..Self::default()
78 }
79 }
80
81 pub fn dryness(mut self, strength: f32) -> Self {
84 self.dryness = strength;
85 self
86 }
87
88 pub fn bleed(mut self, radius: f32) -> Self {
93 self.bleed = radius;
94 self
95 }
96
97 pub fn edge_roughness(mut self, amplitude: f32) -> Self {
101 self.roughness = amplitude;
102 self
103 }
104
105 pub fn granulation(mut self, strength: f32) -> Self {
109 self.granulation = strength;
110 self
111 }
112
113 pub fn fibers(mut self, strength: f32) -> Self {
120 self.fibers = strength;
121 self
122 }
123
124 pub fn paper(mut self, seed: u32, strength: f32, scale: f32) -> Self {
130 self.paper = Some((seed, strength, scale));
131 self
132 }
133
134 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 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
347fn 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}