git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit5f20c2c5960f154b1ff8179b9ca3ee78b5ddf329
parentd294f59d8b
authorLucas Galante <[email protected]>
date2026-09-14 10:20
fix(ramp): smooth ramps are a monotone cubic, not per-segment smoothstep

Blending each segment with smoothstep forces zero slope at every key, so
any ramp with more than two keys was a chain of little bumps — a custom
wall profile read as jagged and uneven. sample_ramp_keys now draws a
monotone cubic through the keys (Fritsch–Butland tangents, zero at the
ends and at local extrema, cubic Hermite segments): C1, through every
key, no overshoot. A two-key ramp is byte-identical to before, so the
identity sentinel and simple eases keep their look. The Ramp widget and
RampPreview delegate to it instead of carrying their own copies.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 src/layout.rs                    | 152 ++++++++++++++++++++++++++++++++++-----
 src/widget/input/ramp.rs         |  36 ++--------
 src/widget/input/ramp_preview.rs |  32 +--------
 3 files changed, 146 insertions(+), 74 deletions(-)

diff --git a/src/layout.rs b/src/layout.rs
index e46b7fc..c55e7e6 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -1797,10 +1797,23 @@ static ROLL_PROFILE: std::sync::RwLock<Option<[f32; BEVEL_PROFILE_SAMPLES]>> =
     std::sync::RwLock::new(None);
 static ROLL_PROFILE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
 
-/// Evaluate a ramp key list at `t` — the same piecewise interpolation
-/// `cce_ui::widget::Ramp::get_interpolated_value` draws, so the bevel renders
-/// exactly the curve the ramp widget shows (`smooth` = the widget's Bezier line
-/// type: smoothstep blending between keys; else linear).
+/// Evaluate a ramp key list at `t` — THE ramp interpolation of the DE.
+/// `cce_ui::widget::Ramp` draws it, `RampPreview` previews it, the relief
+/// profile LUTs sample it, and cce-window-manager's camera speed ramp mirrors
+/// it verbatim (that crate stays dependency-minimal), so a curve sculpted in
+/// the widget is exactly the curve every consumer evaluates. Keys are
+/// `(pos, value)` sorted by pos; outside the key range the end values hold.
+///
+/// `smooth` is the widget's curved line type: a **monotone cubic** through
+/// the keys (Fritsch–Butland tangents, cubic Hermite segments) — C1, passes
+/// through every key, never overshoots a key, and flattens only at the ends
+/// and at genuine local extrema. It used to be a smoothstep blend PER
+/// SEGMENT, which forces zero slope at every key: a curve with more than two
+/// keys came out as a chain of little bumps, and a wall profile built from
+/// it read as jagged and uneven where a smooth slope was drawn. A two-key
+/// ramp is unchanged — zero tangents at both ends make the single Hermite
+/// segment exactly the old smoothstep — so the identity sentinel and every
+/// simple ease keep their look. `false` is straight segments.
 pub fn sample_ramp_keys(keys: &[(f32, f32)], smooth: bool, t: f32) -> f32 {
     let Some(first) = keys.first() else { return 0.0 };
     let last = keys.last().unwrap();
@@ -1810,23 +1823,130 @@ pub fn sample_ramp_keys(keys: &[(f32, f32)], smooth: bool, t: f32) -> f32 {
     if t >= last.0 {
         return last.1;
     }
-    for pair in keys.windows(2) {
-        let (k1, k2) = (pair[0], pair[1]);
-        if t >= k1.0 && t <= k2.0 {
-            let range = k2.0 - k1.0;
-            if range.abs() < 0.0001 {
-                return k1.1;
-            }
-            let mut w = (t - k1.0) / range;
-            if smooth {
-                w = w * w * (3.0 - 2.0 * w);
-            }
-            return k1.1 * (1.0 - w) + k2.1 * w;
+    for i in 0..keys.len() - 1 {
+        let ((x0, y0), (x1, y1)) = (keys[i], keys[i + 1]);
+        if t < x0 || t > x1 {
+            continue;
+        }
+        let h = x1 - x0;
+        if h.abs() < 0.0001 {
+            return y0;
         }
+        let s = (t - x0) / h;
+        if !smooth {
+            return y0 + (y1 - y0) * s;
+        }
+        let (m0, m1) = (ramp_key_tangent(keys, i), ramp_key_tangent(keys, i + 1));
+        let (s2, s3) = (s * s, s * s * s);
+        let h00 = 2.0 * s3 - 3.0 * s2 + 1.0;
+        let h10 = s3 - 2.0 * s2 + s;
+        let h01 = -2.0 * s3 + 3.0 * s2;
+        let h11 = s3 - s2;
+        return h00 * y0 + h10 * h * m0 + h01 * y1 + h11 * h * m1;
     }
     first.1
 }
 
+/// The monotone cubic's tangent (dy/dpos) at key `i`: zero at either end and
+/// at any local extremum (so the curve never overshoots a key), otherwise the
+/// Fritsch–Butland weighted harmonic mean of the two neighbouring secants —
+/// the shape-preserving choice, which keeps every segment monotone whenever
+/// its keys are.
+fn ramp_key_tangent(keys: &[(f32, f32)], i: usize) -> f32 {
+    if i == 0 || i + 1 >= keys.len() {
+        return 0.0;
+    }
+    let ((xp, yp), (x, y), (xn, yn)) = (keys[i - 1], keys[i], keys[i + 1]);
+    let (h0, h1) = (x - xp, xn - x);
+    if h0 <= 0.0001 || h1 <= 0.0001 {
+        return 0.0;
+    }
+    let (d0, d1) = ((y - yp) / h0, (yn - y) / h1);
+    if d0 * d1 <= 0.0 {
+        return 0.0;
+    }
+    let (w0, w1) = (2.0 * h1 + h0, h1 + 2.0 * h0);
+    (w0 + w1) / (w0 / d0 + w1 / d1)
+}
+
+#[cfg(test)]
+mod ramp_sampling_tests {
+    use super::sample_ramp_keys;
+
+    #[test]
+    fn two_key_smooth_is_exactly_smoothstep() {
+        let keys = [(0.0, 0.0), (1.0, 1.0)];
+        for i in 0..=20 {
+            let t = i as f32 / 20.0;
+            let ss = t * t * (3.0 - 2.0 * t);
+            assert!((sample_ramp_keys(&keys, true, t) - ss).abs() < 1e-6, "t={t}");
+        }
+    }
+
+    #[test]
+    fn passes_through_every_key_and_holds_the_ends() {
+        let keys = [(0.0, 0.0), (0.15, 0.45), (0.35, 0.7), (0.55, 0.78), (0.75, 0.85), (1.0, 1.0)];
+        for &(p, v) in &keys {
+            assert!((sample_ramp_keys(&keys, true, p) - v).abs() < 1e-6, "key {p}");
+        }
+        assert_eq!(sample_ramp_keys(&keys, true, -1.0), 0.0);
+        assert_eq!(sample_ramp_keys(&keys, true, 2.0), 1.0);
+    }
+
+    #[test]
+    fn monotone_keys_give_a_monotone_curve_without_wobble() {
+        // The wall profile that came out as a chain of bumps under the old
+        // per-segment smoothstep.
+        let keys = [(0.0, 0.0), (0.15, 0.45), (0.35, 0.7), (0.55, 0.78), (0.75, 0.85), (1.0, 1.0)];
+        let mut last = -1.0f32;
+        let mut slopes = Vec::new();
+        for i in 0..=400 {
+            let t = i as f32 / 400.0;
+            let v = sample_ramp_keys(&keys, true, t);
+            assert!(v >= last - 1e-6, "not monotone at t={t}: {v} < {last}");
+            slopes.push(v - last);
+            last = v;
+        }
+        // No wobble: the slope at an INTERIOR key is a real slope, not the
+        // zero the old blend pinned there (0.35: secants 1.25 and 0.4 either
+        // side — the harmonic mean is well above half the smaller one).
+        let dv = (sample_ramp_keys(&keys, true, 0.355) - sample_ramp_keys(&keys, true, 0.345)) / 0.01;
+        assert!(dv > 0.3, "slope at key 0.35 is {dv}");
+        // …and the slope never flips sign back and forth between keys: at
+        // most one local slope maximum per segment would be a stretch to
+        // assert, so pin the direct symptom — the curve stays inside the
+        // key hull (no overshoot beyond the neighbouring key values).
+        for i in 0..keys.len() - 1 {
+            let (a, b) = (keys[i], keys[i + 1]);
+            for j in 1..10 {
+                let t = a.0 + (b.0 - a.0) * j as f32 / 10.0;
+                let v = sample_ramp_keys(&keys, true, t);
+                assert!(v >= a.1.min(b.1) - 1e-6 && v <= a.1.max(b.1) + 1e-6, "overshoot at t={t}: {v}");
+            }
+        }
+    }
+
+    #[test]
+    fn a_peak_is_flat_at_the_peak_and_never_overshoots() {
+        // The desktop overview speed ramp: rises then falls.
+        let keys = [(0.0, 0.15), (0.4, 1.0), (1.0, 0.1)];
+        assert!((sample_ramp_keys(&keys, true, 0.4) - 1.0).abs() < 1e-6);
+        for i in 0..=100 {
+            let v = sample_ramp_keys(&keys, true, i as f32 / 100.0);
+            assert!(v <= 1.0 + 1e-6 && v >= 0.1 - 1e-6, "overshoot {v}");
+        }
+        let near = sample_ramp_keys(&keys, true, 0.39);
+        assert!(near > 0.99, "flat at the extremum: {near}");
+    }
+
+    #[test]
+    fn linear_is_untouched() {
+        let keys = [(0.0, 0.0), (0.5, 1.0), (1.0, 0.0)];
+        assert!((sample_ramp_keys(&keys, false, 0.25) - 0.5).abs() < 1e-6);
+        assert!((sample_ramp_keys(&keys, false, 0.75) - 0.5).abs() < 1e-6);
+    }
+}
+
 /// Install a custom bevel/carve profile from ramp keys (`(pos, value)`, both
 /// 0..1, sorted by pos). Sampled into the slope LUT the shader's `carve_slope`
 /// reads in place of its analytic smoothstep — every recess/boss/ridge wall in
diff --git a/src/widget/input/ramp.rs b/src/widget/input/ramp.rs
index e26bf95..a02935d 100644
--- a/src/widget/input/ramp.rs
+++ b/src/widget/input/ramp.rs
@@ -241,38 +241,16 @@ impl Ramp {
         self.just_changed = true;
     }
     
+    /// The curve's value at `t` — [`crate::layout::sample_ramp_keys`], the
+    /// DE's one ramp interpolation, so what this widget draws is exactly
+    /// what every consumer of its spec string evaluates.
     pub fn get_interpolated_value(&self, t: f32) -> f32 {
-        if self.keys.is_empty() {
-            return 0.0;
-        }
-        if t <= self.keys[0].pos {
-            return self.keys[0].value;
-        }
-        if t >= self.keys[self.keys.len() - 1].pos {
-            return self.keys[self.keys.len() - 1].value;
-        }
-        
-        for i in 0..self.keys.len() - 1 {
-            let k1 = &self.keys[i];
-            let k2 = &self.keys[i+1];
-            if t >= k1.pos && t <= k2.pos {
-                let range = k2.pos - k1.pos;
-                if range.abs() < 0.0001 {
-                    return k1.value;
-                }
-                let w = (t - k1.pos) / range;
-                if self.line_type_dropdown.selected == 1 {
-                    let w_smooth = w * w * (3.0 - 2.0 * w);
-                    return k1.value * (1.0 - w_smooth) + k2.value * w_smooth;
-                } else {
-                    return k1.value * (1.0 - w) + k2.value * w;
-                }
-            }
-        }
-        self.keys[0].value
+        let keys: Vec<(f32, f32)> = self.keys.iter().map(|k| (k.pos, k.value)).collect();
+        crate::layout::sample_ramp_keys(&keys, self.smooth(), t)
     }
 
-    /// Whether segments blend with smoothstep (the Bezier line type) vs linearly.
+    /// Whether the curve is the smooth (monotone cubic) line type vs straight
+    /// segments — see [`crate::layout::sample_ramp_keys`].
     pub fn smooth(&self) -> bool {
         self.line_type_dropdown.selected == 1
     }
diff --git a/src/widget/input/ramp_preview.rs b/src/widget/input/ramp_preview.rs
index 1be4b79..c1e866c 100644
--- a/src/widget/input/ramp_preview.rs
+++ b/src/widget/input/ramp_preview.rs
@@ -43,36 +43,10 @@ impl RampPreview {
         }
     }
 
-    /// The curve's value at `t` — endpoint-clamped, per-segment linear or
-    /// smoothstep, mirroring `Ramp::get_interpolated_value` and the policy
-    /// crate's `ramp_value`.
+    /// The curve's value at `t` — [`crate::layout::sample_ramp_keys`], the
+    /// same interpolation `Ramp` draws and every consumer evaluates.
     fn value_at(&self, t: f32) -> f32 {
-        let keys = &self.keys;
-        if keys.is_empty() {
-            return 0.0;
-        }
-        if t <= keys[0].0 {
-            return keys[0].1;
-        }
-        if t >= keys[keys.len() - 1].0 {
-            return keys[keys.len() - 1].1;
-        }
-        for pair in keys.windows(2) {
-            let (p1, v1) = pair[0];
-            let (p2, v2) = pair[1];
-            if t >= p1 && t <= p2 {
-                let range = p2 - p1;
-                if range.abs() < 1e-4 {
-                    return v1;
-                }
-                let mut w = (t - p1) / range;
-                if self.smooth {
-                    w = w * w * (3.0 - 2.0 * w);
-                }
-                return v1 * (1.0 - w) + v2 * w;
-            }
-        }
-        keys[keys.len() - 1].1
+        crate::layout::sample_ramp_keys(&self.keys, self.smooth, t)
     }
 }