1use 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#[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 pub fn new(seed: u32) -> Self {
110 Self {
111 seed,
112 ..Self::default()
113 }
114 }
115
116 pub fn dryness(mut self, strength: f32) -> Self {
119 self.dryness = strength;
120 self
121 }
122
123 pub fn bleed(mut self, radius: f32) -> Self {
128 self.bleed = radius;
129 self
130 }
131
132 pub fn edge_roughness(mut self, amplitude: f32) -> Self {
136 self.roughness = amplitude;
137 self
138 }
139
140 pub fn granulation(mut self, strength: f32) -> Self {
144 self.granulation = strength;
145 self
146 }
147
148 pub fn fibers(mut self, strength: f32) -> Self {
155 self.fibers = strength;
156 self
157 }
158
159 pub fn paper(mut self, paper: Paper) -> Self {
170 self.paper = Some(paper);
171 self
172 }
173
174 pub fn paper_transform(mut self, transform: Transform) -> Self {
177 self.paper_transform = transform;
178 self
179 }
180
181 pub fn edge_pooling(mut self, strength: f32, width: f32) -> Self {
186 self.pooling = Some((strength, width));
187 self
188 }
189
190 pub fn edge_darkening(mut self, strength: f64) -> Self {
193 self.edge_darkening = strength;
194 self
195 }
196
197 pub fn build_up(mut self, strength: f64) -> Self {
200 self.build_up = strength;
201 self
202 }
203
204 pub fn edge_anisotropy(mut self, ratio: f64) -> Self {
206 self.anisotropy = ratio;
207 self
208 }
209
210 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 fn paper_space(&self, point: motion_core::Point) -> motion_core::Point {
260 self.paper_transform.apply(point)
261 }
262
263 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#[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 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
465fn 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}