git.lucas.co / cce-window-manager
window management library
git clone https://git.lucas.co/cce-window-manager.git

commit4a04656648556fd7f08279a28e5e435aa0f63f9d
parent8f7f4a33fb
authorLucas Galante <[email protected]>
date2026-09-14 10:20
fix(ramp): mirror cce-ui's monotone-cubic smooth ramp

The camera speed ramp mirrors cce-ui's sampler so the curve sculpted in
the widget is the curve evaluated here; cce-ui's smooth mode is now a
monotone cubic through the keys instead of a per-segment smoothstep, so
this copy follows. Two-key ramps are unchanged. Pinned samples test that
the two copies stay in step.

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

 src/ramp.rs | 93 ++++++++++++++++++++++++++++++++++++++++++++++++-------------
 1 file changed, 73 insertions(+), 20 deletions(-)

diff --git a/src/ramp.rs b/src/ramp.rs
index 311ae9f..70b2dd9 100644
--- a/src/ramp.rs
+++ b/src/ramp.rs
@@ -2,11 +2,12 @@
 //
 // The input is the DE-wide ramp spec string written by cce-ui's Ramp widget
 // (`format_ramp_spec`): `"linear;0.000:0.100,0.500:1.000,1.000:0.050"` —
-// keys are `time:speed` pairs in [0,1]², and the `smooth` head blends
-// segments with smoothstep instead of linearly. The tiny parser and the
-// per-segment interpolation are MIRRORED from cce-ui (this crate stays
-// dependency-minimal), so the curve sculpted in the widget is exactly the
-// curve evaluated here.
+// keys are `time:speed` pairs in [0,1]², and the `smooth` head draws a
+// monotone cubic through the keys instead of straight segments. The tiny
+// parser and the interpolation are MIRRORED from cce-ui
+// (`layout::sample_ramp_keys`; this crate stays dependency-minimal), so the
+// curve sculpted in the widget is exactly the curve evaluated here — keep
+// the two in step.
 //
 // The ramp is a SPEED profile over normalized time. Construction integrates
 // it once into a cumulative-progress table normalized to end at exactly 1,
@@ -39,8 +40,11 @@ pub fn parse_spec(spec: &str) -> Option<(Vec<(f32, f32)>, bool)> {
     Some((keys, smooth))
 }
 
-/// The ramp's value at `t` — endpoint-clamped, per-segment linear or
-/// smoothstep blend. Mirrors cce-ui's `Ramp::get_interpolated_value`.
+/// The ramp's value at `t` — endpoint-clamped; `smooth` is a monotone cubic
+/// through the keys (Fritsch–Butland tangents, zero at the ends and at local
+/// extrema, cubic Hermite segments), else straight segments. Mirrors cce-ui's
+/// `layout::sample_ramp_keys` exactly; a two-key smooth ramp is the plain
+/// smoothstep.
 fn value_at(keys: &[(f32, f32)], smooth: bool, t: f32) -> f32 {
     if keys.is_empty() {
         return 0.0;
@@ -52,21 +56,48 @@ fn value_at(keys: &[(f32, f32)], smooth: bool, t: f32) -> f32 {
         return keys[keys.len() - 1].1;
     }
     for i in 0..keys.len() - 1 {
-        let (p1, v1) = keys[i];
-        let (p2, v2) = keys[i + 1];
-        if t >= p1 && t <= p2 {
-            let range = p2 - p1;
-            if range.abs() < 0.0001 {
-                return v1;
-            }
-            let w = (t - p1) / range;
-            let w = if smooth { w * w * (3.0 - 2.0 * w) } else { w };
-            return v1 * (1.0 - w) + v2 * w;
+        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) = (key_tangent(keys, i), 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;
     }
     keys[0].1
 }
 
+/// Tangent at key `i` for `value_at`'s smooth mode — mirrors cce-ui's
+/// `layout::ramp_key_tangent`.
+fn 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)
+}
+
 /// A speed profile integrated into a normalized progress curve.
 #[derive(Debug, Clone)]
 pub struct SpeedRamp {
@@ -167,13 +198,35 @@ mod tests {
 
     #[test]
     fn smooth_matches_widget_semantics() {
-        // One segment 0→1 with smoothstep: value at midpoint is 0.5 (the
-        // blend is symmetric), and the curve is steeper mid-segment than
+        // One segment 0→1: exactly the smoothstep (zero end tangents), so
+        // the midpoint is 0.5 and the curve is steeper mid-segment than
         // linear at the edges.
         let (keys, smooth) = parse_spec("smooth;0.0:0.0,1.0:1.0").unwrap();
         assert!(smooth);
-        assert!((value_at(&keys, true, 0.5) - 0.5).abs() < 1e-6);
+        for i in 0..=10 {
+            let t = i as f32 / 10.0;
+            assert!((value_at(&keys, true, t) - t * t * (3.0 - 2.0 * t)).abs() < 1e-6);
+        }
         assert!(value_at(&keys, true, 0.25) < 0.25);
         assert!(value_at(&keys, true, 0.75) > 0.75);
     }
+
+    #[test]
+    fn smooth_mirrors_cce_ui_sample_ramp_keys() {
+        // Pinned samples of cce-ui's `layout::sample_ramp_keys` on the
+        // overview ramp and a six-key monotone profile: if either copy
+        // drifts, this and the cce-ui test disagree.
+        let (keys, _) = parse_spec("smooth;0.000:0.150,0.400:1.000,1.000:0.100").unwrap();
+        assert!((value_at(&keys, true, 0.4) - 1.0).abs() < 1e-6);
+        assert!(value_at(&keys, true, 0.39) > 0.99, "flat at the peak");
+        let (keys, _) = parse_spec("smooth;0:0,0.15:0.45,0.35:0.7,0.55:0.78,0.75:0.85,1:1").unwrap();
+        let mut last = -1.0f32;
+        for i in 0..=200 {
+            let v = value_at(&keys, true, i as f32 / 200.0);
+            assert!(v >= last - 1e-6, "monotone");
+            last = v;
+        }
+        let dv = (value_at(&keys, true, 0.355) - value_at(&keys, true, 0.345)) / 0.01;
+        assert!(dv > 0.3, "a real slope at an interior key, not the old zero: {dv}");
+    }
 }