GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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)
}
}