hokusai_core/mapping.rs
1//! Per-setting input → value mapping (piecewise-linear, matches libmypaint).
2
3use crate::input::BrushInput;
4
5#[derive(Debug, Clone, Default, PartialEq)]
6pub struct InputMapping {
7 pub input: BrushInput,
8 /// `(input_value, output_offset)` knots. libmypaint requires `x` strictly
9 /// ascending. Output is added to `base_value` after summing all inputs.
10 pub points: Vec<(f32, f32)>,
11}
12
13impl InputMapping {
14 pub fn new(input: BrushInput) -> Self {
15 Self {
16 input,
17 points: Vec::new(),
18 }
19 }
20
21 /// Evaluate the piecewise-linear curve at `x`. Mirrors libmypaint's
22 /// `mypaint_mapping_calculate` (mypaint-mapping.c): starts with the
23 /// first two knots, walks forward while `x > x1`, and if the resulting
24 /// bracket has `x0 == x1` or `y0 == y1` returns `y0` directly to dodge
25 /// division by zero on duplicate-x knots (Dieterle/Posterizer's
26 /// opaque_multiply curve has `[(0,0),(0,1),(1,1)]` exactly).
27 pub fn eval(&self, x: f32) -> f32 {
28 let p = &self.points;
29 match p.len() {
30 0 => 0.0,
31 1 => p[0].1,
32 _ => {
33 // libmypaint scans the points left-to-right starting from the
34 // second one; whatever segment we land on at the end of the
35 // scan is what gets used (which means below-range input
36 // clamps to the first segment, above-range extrapolates from
37 // the last segment, with the same special case applied).
38 let (mut x0, mut y0) = p[0];
39 let (mut x1, mut y1) = p[1];
40 #[allow(clippy::needless_range_loop)]
41 // libmypaint's mapping_calculate walks indices; iterator rewrite would obscure
42 for i in 2..p.len() {
43 if x <= x1 {
44 break;
45 }
46 x0 = x1;
47 y0 = y1;
48 x1 = p[i].0;
49 y1 = p[i].1;
50 }
51 if x0 == x1 || y0 == y1 {
52 y0
53 } else {
54 // Linear interpolation. The formula matches libmypaint's
55 // `(y1*(x-x0) + y0*(x1-x)) / (x1-x0)` and extrapolates
56 // naturally when x is outside [x0, x1].
57 (y1 * (x - x0) + y0 * (x1 - x)) / (x1 - x0)
58 }
59 }
60 }
61 }
62}
63
64#[derive(Debug, Clone, Default, PartialEq)]
65pub struct SettingValue {
66 pub base_value: f32,
67 pub inputs: Vec<InputMapping>,
68 /// Input mappings on this setting whose input name hokusai doesn't
69 /// know about. Kept verbatim so a `.myb` parse + serialize cycle
70 /// stays lossless even for brush packs that use inputs hokusai
71 /// hasn't ported yet (or third-party extensions). The mapping is
72 /// not consulted during evaluation.
73 pub unknown_inputs: std::collections::BTreeMap<String, Vec<(f32, f32)>>,
74}
75
76impl SettingValue {
77 pub const fn constant(v: f32) -> Self {
78 Self {
79 base_value: v,
80 inputs: Vec::new(),
81 unknown_inputs: std::collections::BTreeMap::new(),
82 }
83 }
84}
85
86#[cfg(test)]
87mod tests {
88 use super::*;
89
90 #[test]
91 fn linear_in_range() {
92 let m = InputMapping {
93 input: BrushInput::Pressure,
94 points: vec![(0.0, 0.0), (1.0, 1.0)],
95 };
96 assert!((m.eval(0.5) - 0.5).abs() < 1e-6);
97 }
98
99 #[test]
100 fn extrapolates_with_segment_slope() {
101 let m = InputMapping {
102 input: BrushInput::Pressure,
103 points: vec![(0.0, 0.0), (1.0, 2.0)],
104 };
105 assert!((m.eval(2.0) - 4.0).abs() < 1e-6);
106 assert!((m.eval(-1.0) - (-2.0)).abs() < 1e-6);
107 }
108
109 #[test]
110 fn duplicate_x_returns_first_y() {
111 // Dieterle/Posterizer's opaque_multiply: `[(0,0),(0,1),(1,1)]`.
112 // libmypaint walks left-to-right and reads y0 at duplicate-x or
113 // duplicate-y knots. Before this was fixed, hokusai produced NaN
114 // for the x = 0 input because the first segment had Δx = 0.
115 let m = InputMapping {
116 input: BrushInput::Pressure,
117 points: vec![(0.0, 0.0), (0.0, 1.0), (1.0, 1.0)],
118 };
119 assert_eq!(m.eval(0.0), 0.0);
120 assert_eq!(m.eval(0.5), 1.0);
121 assert_eq!(m.eval(1.0), 1.0);
122 // Below the first knot still uses the first segment, returning y0.
123 assert_eq!(m.eval(-0.5), 0.0);
124 }
125
126 #[test]
127 fn staircase_curve_steps_correctly() {
128 // Dieterle/Posterizer's custom_input random curve is a staircase
129 // built from duplicated x knots that step the y value at each
130 // tenth. Verify a couple of step boundaries.
131 let m = InputMapping {
132 input: BrushInput::Random,
133 points: vec![
134 (0.0, -10.0),
135 (0.1, -10.0),
136 (0.1, -8.0),
137 (0.2, -8.0),
138 (0.2, -6.0),
139 (0.3, -6.0),
140 ],
141 };
142 assert_eq!(m.eval(0.05), -10.0);
143 assert_eq!(m.eval(0.15), -8.0);
144 assert_eq!(m.eval(0.25), -6.0);
145 }
146}