window management library
git clone https://git.lucas.co/cce-window-manager.git
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}");
+ }
}