Skip to main content

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}