use motion_core::Transform;
use std::io;
mod brush;
mod lettering;
mod paint;
mod paper;
pub use lettering::StrokeFont;
pub use brush::{
BrushCap, BrushPreset, BrushStroke, BrushStrokeBuilder, BrushTiming, InkFill, InputSample,
NibMode, PigmentMix, PigmentPainting,
};
pub use paper::Paper;
use paper::PaperSampler;
type Point = (f64, f64);
#[derive(Clone, Debug)]
pub struct Ink {
pub(super) seed: u32,
pub(super) dryness: f32,
pub(super) bleed: f32,
pub(super) roughness: f32,
pub(super) granulation: f32,
fibers: f32,
paper: Option<Paper>,
paper_transform: Transform,
pooling: Option<(f32, f32)>,
pub(super) build_up: f64,
pub(super) edge_darkening: f64,
pub(super) anisotropy: f64,
pub(super) height: f64,
}
impl Default for Ink {
fn default() -> Self {
Self {
seed: 0,
dryness: 0.0,
bleed: 0.0,
roughness: 0.0,
granulation: 0.0,
fibers: 0.0,
paper: None,
paper_transform: Transform::IDENTITY,
pooling: None,
build_up: 0.0,
edge_darkening: 0.0,
anisotropy: 1.0,
height: 0.0,
}
}
}
impl Ink {
pub fn new(seed: u32) -> Self {
Self {
seed,
..Self::default()
}
}
pub fn dryness(mut self, strength: f32) -> Self {
self.dryness = strength;
self
}
pub fn bleed(mut self, radius: f32) -> Self {
self.bleed = radius;
self
}
pub fn edge_roughness(mut self, amplitude: f32) -> Self {
self.roughness = amplitude;
self
}
pub fn granulation(mut self, strength: f32) -> Self {
self.granulation = strength;
self
}
pub fn fibers(mut self, strength: f32) -> Self {
self.fibers = strength;
self
}
pub fn paper(mut self, paper: Paper) -> Self {
self.paper = Some(paper);
self
}
pub fn paper_transform(mut self, transform: Transform) -> Self {
self.paper_transform = transform;
self
}
pub fn edge_pooling(mut self, strength: f32, width: f32) -> Self {
self.pooling = Some((strength, width));
self
}
pub fn edge_darkening(mut self, strength: f64) -> Self {
self.edge_darkening = strength;
self
}
pub fn build_up(mut self, strength: f64) -> Self {
self.build_up = strength;
self
}
pub fn edge_anisotropy(mut self, ratio: f64) -> Self {
self.anisotropy = ratio;
self
}
pub fn height_map(mut self, strength: f64) -> Self {
self.height = strength;
self
}
pub(crate) fn padding(&self) -> io::Result<f32> {
let padding = self.bleed + self.roughness;
if ![
self.dryness,
self.bleed,
self.roughness,
self.granulation,
self.fibers,
padding,
]
.iter()
.all(|value| value.is_finite())
|| !(0.0..=1.0).contains(&self.dryness)
|| !(0.0..=1.0).contains(&self.granulation)
|| !(0.0..=1.0).contains(&self.fibers)
|| self.paper.as_ref().is_some_and(|paper| !paper.is_valid())
|| !self.paper_transform.is_finite()
|| self.pooling.is_some_and(|(strength, width)| {
!strength.is_finite()
|| !(0.0..=1.0).contains(&strength)
|| !width.is_finite()
|| width <= 0.0
})
|| self.bleed < 0.0
|| self.roughness < 0.0
|| !self.build_up.is_finite()
|| !(0.0..=1.0).contains(&self.build_up)
|| !self.edge_darkening.is_finite()
|| !(0.0..=1.0).contains(&self.edge_darkening)
|| !self.anisotropy.is_finite()
|| !(0.1..=10.0).contains(&self.anisotropy)
|| !self.height.is_finite()
|| !(0.0..=16.0).contains(&self.height)
{
return Err(invalid(
"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",
));
}
Ok(padding)
}
fn paper_space(&self, point: motion_core::Point) -> motion_core::Point {
self.paper_transform.apply(point)
}
fn paper_sampler(&self) -> Option<PaperSampler> {
match &self.paper {
Some(Paper::Texture {
texture,
grain_scale,
absorbency,
}) => Some(PaperSampler::new(
texture.clone(),
*grain_scale,
*absorbency,
)),
_ => None,
}
}
#[cfg(test)]
pub(super) fn has_fill_edges(&self) -> bool {
self.bleed > 0.0
|| self.roughness > 0.0
|| self.pooling.is_some_and(|(strength, _)| strength > 0.0)
}
pub(super) fn sample(&self, point: Point, canvas: Point) -> InkSample {
let dry = (self.dryness > 0.0).then(|| DrySample::new(point, self.seed));
let mut density = dry.map_or(1.0, |dry| dry.coverage(f64::from(self.dryness)));
let mut pigment = 1.0;
if self.granulation > 0.0 {
let cloud = noise((point.0 * 0.08, point.1 * 0.08), self.seed.wrapping_add(43));
let grain = noise((point.0 * 0.65, point.1 * 0.65), self.seed.wrapping_add(71));
let factor =
1.0 - f64::from(self.granulation) * (0.2 + 0.8 * (cloud * 0.7 + grain * 0.3));
density *= factor;
pigment *= factor;
}
if let Some(Paper::Procedural {
seed,
strength,
scale,
}) = self.paper
{
let paper = (canvas.0 / f64::from(scale), canvas.1 / f64::from(scale));
let horizontal = noise((paper.0 * 0.08, paper.1 * 2.8), seed);
let vertical = noise((paper.0 * 2.5, paper.1 * 0.12), seed.wrapping_add(101));
let grain = noise((paper.0 * 0.7, paper.1 * 0.7), seed.wrapping_add(137));
let factor = 1.0
- f64::from(strength)
* (0.15 + 0.85 * (horizontal * 0.5 + vertical * 0.3 + grain * 0.2));
density *= factor;
pigment *= factor;
}
InkSample {
edge: if self.roughness > 0.0 {
f64::from(self.roughness)
* (noise((point.0 * 0.22, point.1 * 0.22), self.seed.wrapping_add(29)) * 2.0
- 1.0)
} else {
0.0
},
bleed: if self.bleed > 0.0 {
f64::from(self.bleed)
* (0.65
+ 0.35
* noise((point.0 * 0.35, point.1 * 0.35), self.seed.wrapping_add(61)))
} else {
0.0
},
density,
pigment,
point,
shade: Some(Shade {
seed: self.seed,
dryness: self.dryness,
fibers: self.fibers,
pooling: self.pooling,
}),
dry,
}
}
}
#[derive(Clone, Copy)]
pub(super) struct InkSample {
edge: f64,
bleed: f64,
density: f64,
pigment: f64,
point: Point,
shade: Option<Shade>,
dry: Option<DrySample>,
}
#[derive(Clone, Copy)]
struct Shade {
seed: u32,
dryness: f32,
fibers: f32,
pooling: Option<(f32, f32)>,
}
impl Default for InkSample {
fn default() -> Self {
Self {
edge: 0.0,
bleed: 0.0,
density: 1.0,
pigment: 1.0,
point: (0.0, 0.0),
shade: None,
dry: None,
}
}
}
impl InkSample {
#[cfg(test)]
pub(super) fn fill_coverage(self, coverage: f64) -> f64 {
coverage * self.density
}
pub(super) fn fill_edge_coverage(self, distance: f64, metric: f64) -> f64 {
self.coverage(distance, metric) * (self.density * self.pool_density(distance))
}
fn pool_density(self, distance: f64) -> f64 {
self.shade
.and_then(|shade| shade.pooling)
.map_or(1.0, |(strength, width)| {
let rim = (1.0 - (distance - self.edge).abs() / f64::from(width)).clamp(0.0, 1.0);
1.0 - f64::from(strength) * 0.45 * (1.0 - rim * rim)
})
}
pub(super) fn coverage(self, distance: f64, metric: f64) -> f64 {
let distance = distance - self.edge;
let core = (0.5 - distance / metric).clamp(0.0, 1.0);
let halo = if self.bleed > 0.0 {
let remaining = (1.0 - distance.max(0.0) / self.bleed).clamp(0.0, 1.0);
remaining * remaining * 0.35
} else {
0.0
};
core + (1.0 - core) * halo
}
pub(super) fn shade(self, coverage: f64, distance: f64, fiber: Point, load: f64) -> f64 {
if load <= 0.0 {
return 0.0;
}
let mut density = self.density;
if let Some(shade) = self.shade {
if shade.fibers > 0.0 || load < 1.0 {
let dryness = 1.0 - (1.0 - f64::from(shade.dryness)) * load;
let isotropic = self
.dry
.unwrap_or_else(|| DrySample::new(self.point, shade.seed))
.coverage(dryness);
let dry = if shade.fibers > 0.0 {
let directional = dry_coverage(fiber, shade.seed, dryness);
isotropic + (directional - isotropic) * f64::from(shade.fibers)
} else {
isotropic
};
density = dry * self.pigment;
}
density *= self.pool_density(distance);
}
coverage * density * load
}
}
fn invalid(message: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidInput, message)
}
fn dry_coverage(point: Point, seed: u32, dryness: f64) -> f64 {
DrySample::new(point, seed).coverage(dryness)
}
#[derive(Clone, Copy)]
struct DrySample {
grain: f64,
fiber: f64,
}
impl DrySample {
fn new(point: Point, seed: u32) -> Self {
Self {
grain: noise((point.0 * 1.3, point.1 * 1.3), seed),
fiber: noise((point.0 * 0.15, point.1 * 3.0), seed.wrapping_add(17)),
}
}
fn coverage(self, dryness: f64) -> f64 {
let density =
((self.fiber - dryness * 0.7) * 8.0).clamp(0.0, 1.0) * (0.5 + 0.5 * self.grain);
1.0 - dryness + dryness * density
}
}
fn noise(point: Point, seed: u32) -> f64 {
motion_core::noise::value2([point.0, point.1], seed) * 0.5 + 0.5
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fill_edges_have_bounded_wash_and_relative_rim_density() {
let point = (13.5, 24.5);
let ink = Ink::new(17).bleed(4.0).edge_pooling(1.0, 2.0);
assert!(ink.has_fill_edges());
assert!(
!Ink::new(17)
.fibers(1.0)
.edge_pooling(0.0, 2.0)
.has_fill_edges()
);
let sample = ink.sample(point, point);
assert!(sample.fill_edge_coverage(1.0, 1.0) > 0.0);
assert_eq!(sample.fill_edge_coverage(5.0, 1.0), 0.0);
assert!(sample.fill_edge_coverage(-0.5, 1.0) > sample.fill_edge_coverage(-5.0, 1.0));
let fibers = ink.fibers(1.0).sample(point, point);
assert_eq!(
sample.fill_edge_coverage(-0.5, 1.0),
fibers.fill_edge_coverage(-0.5, 1.0)
);
}
#[test]
fn fill_density_preserves_coverage_and_is_stateless() {
let point = (13.5, 24.5);
let neutral = Ink::new(17).sample(point, point);
let ink = Ink::new(17)
.dryness(0.4)
.granulation(0.6)
.paper(Paper::Procedural {
seed: 211,
strength: 0.7,
scale: 1.5,
});
for coverage in [0.0, 0.25, 0.5, 0.75, 1.0] {
assert_eq!(neutral.fill_coverage(coverage), coverage);
let first = ink.sample(point, point).fill_coverage(coverage);
let _ = ink.sample((40.5, 8.5), (8.5, 40.5));
assert_eq!(first, ink.sample(point, point).fill_coverage(coverage));
assert!(first >= 0.0 && first <= coverage);
}
assert!(ink.sample(point, point).fill_coverage(1.0) < 1.0);
let paper = Ink::new(17).paper(Paper::Procedural {
seed: 211,
strength: 0.7,
scale: 1.5,
});
assert_eq!(
paper.sample(point, point).fill_coverage(1.0),
paper.sample((90.0, 12.0), point).fill_coverage(1.0)
);
}
#[test]
fn neutral_fibers_preserve_reference_dry_coverage() {
for point in [(2.5, 3.5), (15.5, 9.5), (37.5, 24.5)] {
let material = Ink::new(17).dryness(0.7).sample(point, point);
for distance in [-4.0, -0.2, 0.3, 2.0] {
let expected = (0.5_f64 - distance).clamp(0.0, 1.0)
* dry_coverage(point, 17, f64::from(0.7_f32));
assert_eq!(
material.shade(material.coverage(distance, 1.0), distance, (0.0, 0.0), 1.0),
expected
);
}
}
}
#[test]
fn paper_is_canvas_anchored_and_pooling_keeps_a_darker_rim() {
let paper = Ink::new(17).paper(Paper::Procedural {
seed: 211,
strength: 0.7,
scale: 1.5,
});
let canvas = (13.5, 24.5);
assert_eq!(
paper.sample((0.0, 0.0), canvas).density,
paper.sample((90.0, 12.0), canvas).density
);
assert_eq!(
paper.sample((0.0, 0.0), canvas).density,
Ink::new(99)
.paper(Paper::Procedural {
seed: 211,
strength: 0.7,
scale: 1.5
})
.sample((0.0, 0.0), canvas)
.density
);
assert!((1..20).any(|offset| {
paper
.sample((0.0, 0.0), (canvas.0 + f64::from(offset), canvas.1))
.density
!= paper.sample((0.0, 0.0), canvas).density
}));
let pooled = Ink::new(17).edge_pooling(1.0, 2.0).sample(canvas, canvas);
assert!(
pooled.shade(1.0, -0.5, (0.0, 0.0), 1.0) > pooled.shade(1.0, -5.0, (0.0, 0.0), 1.0)
);
assert_eq!(pooled.shade(1.0, -0.5, (0.0, 0.0), 0.0), 0.0);
}
}