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

commit40574c9e033149e5a658e83f93161fbfe2f8ed18
parentf91dc0eaf0
authorLucas Galante <[email protected]>
date2026-07-23 14:12
feat: ramp-defined bevel profiles — carve walls reshape from a user curve

The shape of every recess/boss/ridge wall was baked into the shader as a
smoothstep. It is now definable as a ramp curve:

- layout::set_bevel_profile_keys(keys, smooth) samples the curve (the exact
  interpolation the Ramp widget draws) into a 32-entry slope LUT; a
  generation counter tells renderers to re-upload.
- The 2D shader's WindowInfo UBO grows a profile block (meta + 8 vec4 of
  slope samples); carve_slope() lerps the LUT when installed, keeping the
  analytic smoothstep/smootherstep as the default. Non-monotonic curves
  (rims, ogees) work — slopes may go negative and need not integrate to 1.
- Ramp gains spec-string serialization (format_ramp_spec/parse_ramp_spec,
  "smooth;0.000:0.000,1.000:1.000") wired through Input::value_string, so a
  ramp can live wherever string-valued params travel.
- ParametersBg gains a "ramp" row type: an Adapted<Ramp> per row, seeded
  from the value spec, re-serialized on every edit (click/drag/preset/tick).
  The curve/key geometry no flat tuple view can carry rides the new
  paint_scene_rows(pc) hook, which legacy-hatch hosts call inside their
  scroll clip; the ramp's field dropdowns join the popover surfaces.

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

 src/layout.rs                         | 112 ++++++++++++++++++++++++++
 src/vk/renderer.rs                    |  39 ++++++---
 src/vk/shader2d.wgsl                  |  29 ++++++-
 src/widget/container/parameters_bg.rs | 145 +++++++++++++++++++++++++++++++++-
 src/widget/input/mod.rs               |   2 +-
 src/widget/input/ramp.rs              |  79 +++++++++++++++++-
 src/widget/mod.rs                     |   3 +-
 7 files changed, 392 insertions(+), 17 deletions(-)

diff --git a/src/layout.rs b/src/layout.rs
index 045dde0..126a9ae 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -1539,6 +1539,91 @@ pub fn bevel_width() -> f32 {
     get_style_registry().read().unwrap().get_float("bevel_width").unwrap_or(9.3)
 }
 
+/// Sample count of the custom bevel profile LUT ([`set_bevel_profile_keys`]).
+pub const BEVEL_PROFILE_SAMPLES: usize = 32;
+
+/// The custom bevel/carve height profile, as the slope LUT the renderer uploads
+/// to the 2D shader: slot `i` holds `h'` at `v = (i + 0.5) / N` of the wall's
+/// height curve `h(v)` (`v` runs 0 at the surrounding plateau → 1 at the carve
+/// floor / boss crest; `h` in units of the feature's depth, so a 0→1 curve is
+/// the classic full-depth bevel and a curve ending back at its start height is
+/// a pure decorative rim). `None` = the analytic smoothstep profile.
+static BEVEL_PROFILE: std::sync::RwLock<Option<[f32; BEVEL_PROFILE_SAMPLES]>> =
+    std::sync::RwLock::new(None);
+/// Bumped on every profile change so renderers know to re-upload their LUT.
+static BEVEL_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).
+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();
+    if t <= first.0 {
+        return first.1;
+    }
+    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;
+        }
+    }
+    first.1
+}
+
+/// 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
+/// this process restyles on the next frame. Empty or single-key lists clear
+/// back to the analytic profile ([`clear_bevel_profile`]).
+pub fn set_bevel_profile_keys(keys: &[(f32, f32)], smooth: bool) {
+    if keys.len() < 2 {
+        clear_bevel_profile();
+        return;
+    }
+    let n = BEVEL_PROFILE_SAMPLES;
+    let mut slopes = [0.0f32; BEVEL_PROFILE_SAMPLES];
+    for (i, slot) in slopes.iter_mut().enumerate() {
+        let h0 = sample_ramp_keys(keys, smooth, i as f32 / n as f32);
+        let h1 = sample_ramp_keys(keys, smooth, (i + 1) as f32 / n as f32);
+        *slot = (h1 - h0) * n as f32;
+    }
+    *BEVEL_PROFILE.write().unwrap() = Some(slopes);
+    BEVEL_PROFILE_GEN.fetch_add(1, std::sync::atomic::Ordering::Release);
+}
+
+/// Drop the custom bevel profile — walls return to the analytic smoothstep.
+pub fn clear_bevel_profile() {
+    let mut guard = BEVEL_PROFILE.write().unwrap();
+    if guard.is_some() {
+        *guard = None;
+        BEVEL_PROFILE_GEN.fetch_add(1, std::sync::atomic::Ordering::Release);
+    }
+}
+
+/// The installed profile's slope LUT, if any — what the renderer uploads.
+pub fn bevel_profile_slopes() -> Option<[f32; BEVEL_PROFILE_SAMPLES]> {
+    *BEVEL_PROFILE.read().unwrap()
+}
+
+/// Change counter for [`bevel_profile_slopes`] — a renderer re-uploads when it
+/// differs from the generation it last wrote.
+pub fn bevel_profile_generation() -> u64 {
+    BEVEL_PROFILE_GEN.load(std::sync::atomic::Ordering::Acquire)
+}
+
 /// Padding between the window plate's edge and the objects sitting on it, in
 /// logical px (`style.surface.backplate.padding` in config.kdl). DE-wide so
 /// every app's content sits the same distance off the plate rim.
@@ -5626,5 +5711,32 @@ mod tests {
         assert!((w3.w - 101.05).abs() < 0.01);
         assert!((w3.h - 82.857).abs() < 0.01);
     }
+
+    #[test]
+    fn bevel_profile_lut_integrates_to_the_curves_net_rise() {
+        // The identity 0→1 curve: slopes sum/N to its net rise of 1 (the same
+        // total step the analytic smoothstep carries).
+        crate::layout::set_bevel_profile_keys(&[(0.0, 0.0), (1.0, 1.0)], true);
+        let slopes = crate::layout::bevel_profile_slopes().expect("profile installed");
+        let n = crate::layout::BEVEL_PROFILE_SAMPLES as f32;
+        let rise: f32 = slopes.iter().map(|s| s / n).sum();
+        assert!((rise - 1.0).abs() < 0.001, "net rise {rise}");
+
+        // A rim curve that returns to its start height nets zero.
+        crate::layout::set_bevel_profile_keys(
+            &[(0.0, 0.5), (0.2, 1.0), (0.8, 1.0), (1.0, 0.5)],
+            false,
+        );
+        let slopes = crate::layout::bevel_profile_slopes().unwrap();
+        let rise: f32 = slopes.iter().map(|s| s / n).sum();
+        assert!(rise.abs() < 0.001, "net rise {rise}");
+
+        // Degenerate key lists clear back to the analytic profile; the
+        // generation moves on every change so renderers re-upload.
+        let gen = crate::layout::bevel_profile_generation();
+        crate::layout::set_bevel_profile_keys(&[(0.0, 1.0)], false);
+        assert!(crate::layout::bevel_profile_slopes().is_none());
+        assert!(crate::layout::bevel_profile_generation() > gen);
+    }
 }
 
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index f3680be..511959c 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -85,6 +85,9 @@ const FRAMES_IN_FLIGHT: usize = 2;
 /// size per frame in flight.
 pub const MAX_PLATE_FEATURES: usize = 64;
 const PLATE_FEATURE_BYTES: usize = 48;
+/// shader2d's WindowInfo UBO: [size/clip vec4][bevel-profile meta vec4]
+/// [8 vec4 of profile slope samples].
+const WINDOW_INFO_BYTES: vk::DeviceSize = 160;
 
 pub(crate) struct AllocatedBuffer {
     pub(crate) buffer: vk::Buffer,
@@ -192,6 +195,9 @@ pub struct VkRenderer {
     descriptor_set: vk::DescriptorSet,
     backdrop_sampler: vk::Sampler,
     window_info: AllocatedBuffer,
+    /// The bevel-profile generation `window_info` was last written with —
+    /// `draw_frame_2d` rewrites the UBO when the layout global moves on.
+    profile_gen: u64,
     plate_features: AllocatedBuffer,
 
     frames: Vec<Frame>,
@@ -638,7 +644,7 @@ impl VkRenderer {
         let window_info = create_cpu_buffer(
             &device,
             allocator,
-            16,
+            WINDOW_INFO_BYTES,
             vk::BufferUsageFlags::UNIFORM_BUFFER,
             "window-info",
         );
@@ -686,7 +692,7 @@ impl VkRenderer {
         let buffer_infos = [vk::DescriptorBufferInfo::default()
             .buffer(window_info.buffer)
             .offset(0)
-            .range(16)];
+            .range(WINDOW_INFO_BYTES)];
         let feature_infos = [vk::DescriptorBufferInfo::default()
             .buffer(plate_features.buffer)
             .offset(0)
@@ -776,6 +782,7 @@ impl VkRenderer {
             descriptor_set,
             backdrop_sampler,
             window_info,
+            profile_gen: 0,
             plate_features,
             frames,
             frame_index: 0,
@@ -810,14 +817,21 @@ impl VkRenderer {
     }
 
     fn write_window_info(&mut self) {
-        let data = [
-            self.extent.width as f32,
-            self.extent.height as f32,
-            self.clip_corner_radius(),
-            crate::layout::corner_shape(),
-        ];
+        // [size/clip vec4][profile meta vec4][8 vec4 of profile slope samples]
+        // — must stay in lockstep with shader2d's WindowInfo.
+        let mut data = [0.0f32; WINDOW_INFO_BYTES as usize / 4];
+        data[0] = self.extent.width as f32;
+        data[1] = self.extent.height as f32;
+        data[2] = self.clip_corner_radius();
+        data[3] = crate::layout::corner_shape();
+        if let Some(slopes) = crate::layout::bevel_profile_slopes() {
+            data[4] = 1.0;
+            data[5] = crate::layout::BEVEL_PROFILE_SAMPLES as f32;
+            data[8..8 + slopes.len()].copy_from_slice(&slopes);
+        }
+        self.profile_gen = crate::layout::bevel_profile_generation();
         if let Some(allocation) = self.window_info.allocation.as_mut() {
-            allocation.mapped_slice_mut().unwrap()[..16]
+            allocation.mapped_slice_mut().unwrap()[..WINDOW_INFO_BYTES as usize]
                 .copy_from_slice(bytemuck::cast_slice(&data));
         }
     }
@@ -1178,6 +1192,13 @@ impl VkRenderer {
 
             self.core.device.reset_fences(&[in_flight]).unwrap();
 
+            // Re-upload the bevel-profile LUT when it changed (a live ramp
+            // edit). The other in-flight frame may still read the old bytes —
+            // both are valid profiles, so the one-frame mix is benign.
+            if self.profile_gen != crate::layout::bevel_profile_generation() {
+                self.write_window_info();
+            }
+
             // Upload this frame's plate carves into its slot of the feature
             // UBO (the slot's previous user has fenced, so no race).
             if !frame2d.plate_features.is_empty() {
diff --git a/src/vk/shader2d.wgsl b/src/vk/shader2d.wgsl
index d7aad3d..fefe832 100644
--- a/src/vk/shader2d.wgsl
+++ b/src/vk/shader2d.wgsl
@@ -14,6 +14,13 @@ struct WindowInfo {
     // Corner-shape exponent shared with the plates and the rounded-rect clip:
     // circular arc at 2, superellipse squircle above.
     corner_shape: f32,
+    // Custom bevel/carve profile (cce_ui::layout::set_bevel_profile_keys):
+    // x nonzero enables it, y = live sample count in `profile`.
+    profile_meta: vec4f,
+    // Slope samples of the profile's height curve h(v) (v 0 = plateau, 1 =
+    // carve floor / boss crest), sample i at v = (i + 0.5) / count, packed 4
+    // per vec4. carve_slope reads these in place of its analytic smoothstep.
+    profile: array<vec4f, 8>,
 }
 
 @group(0) @binding(2) var<uniform> window_info: WindowInfo;
@@ -193,11 +200,25 @@ fn roll_slope(f: f32) -> f32 {
 }
 
 // Slope of a carve's transition profile (0 on the surrounding plateau → 1 on
-// the carve floor) at v in [0, 1] across the wall — always >= 0, zero at both
-// ends. The profile is smoothstep normally; smootherstep (zero SECOND
-// derivative at both plateaus) under a continuous-curvature corner_shape —
-// the step's analog of the superellipse roll.
+// the carve floor) at v in [0, 1] across the wall. With a custom profile
+// installed (window_info.profile_meta.x), the slope comes from the uploaded
+// ramp LUT — it may go negative (non-monotonic curves: rims, ogees) and its
+// integral is the curve's net rise, not necessarily 1. Otherwise the analytic
+// default: smoothstep normally, smootherstep (zero SECOND derivative at both
+// plateaus) under a continuous-curvature corner_shape — the step's analog of
+// the superellipse roll.
 fn carve_slope(v: f32) -> f32 {
+    if (window_info.profile_meta.x > 0.5) {
+        let n = window_info.profile_meta.y;
+        // Samples sit at v = (i + 0.5) / n; lerp between the two neighbors.
+        let x = clamp(clamp(v, 0.0, 1.0) * n - 0.5, 0.0, n - 1.0);
+        let i0 = u32(floor(x));
+        let i1 = min(i0 + 1u, u32(n) - 1u);
+        let fr = x - floor(x);
+        let s0 = window_info.profile[i0 >> 2u][i0 & 3u];
+        let s1 = window_info.profile[i1 >> 2u][i1 & 3u];
+        return mix(s0, s1, fr);
+    }
     if (rrect_clip.rect1.w > 2.001) {
         let w = v * (1.0 - v);
         return 30.0 * w * w;
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 51c2720..37b1cd8 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -25,7 +25,7 @@ use crate::colors;
 use crate::scene::layout::Rect;
 use crate::scene::paint::PaintCtx;
 use crate::widget::display::{Float3, TextLabel};
-use crate::widget::input::{Button, ColorSelector, Dropdown, Slider, Spinbox, TextBox, Toggle};
+use crate::widget::input::{Button, ColorSelector, Dropdown, Ramp, Slider, Spinbox, TextBox, Toggle};
 use crate::widget::{
     Adapted, WidgetHost, ElementState, Event, EventCtx, Input, Key, Layout, MouseButton,
     MouseScrollDelta, NamedKey, Paint, ParamController, TextEditorState, UiContext,
@@ -46,6 +46,10 @@ pub struct ParametersBg {
     pub texts: Vec<Option<Adapted<TextBox>>>,
     pub toggles: Vec<Option<Adapted<Toggle>>>,
     pub colors: Vec<Option<crate::widget::Adapted<ColorSelector>>>,
+    /// Ramp-curve rows (`"ramp"` type; value = the ramp spec string). Painted
+    /// scene-path through [`ParametersBg::paint_scene_rows`] — the legacy flat
+    /// views can't carry the curve/key geometry.
+    pub ramps: Vec<Option<Adapted<Ramp>>>,
     /// Titles of the sections the user has collapsed by clicking their header. Keyed by
     /// title so it outlives the row rebuild `set_display_params` runs on every node change.
     collapsed: std::collections::HashSet<String>,
@@ -129,6 +133,7 @@ impl ParametersBg {
             texts: Vec::new(),
             toggles: Vec::new(),
             colors: Vec::new(),
+            ramps: Vec::new(),
             collapsed: std::collections::HashSet::new(),
             visible: true,
             scroll_y: 0.0,
@@ -159,6 +164,9 @@ impl ParametersBg {
             content_h.max(200.0)
         } else if p.2 == "section" {
             24.0
+        } else if p.2 == "ramp" {
+            // Label band + the ramp's graph and control strip.
+            190.0
         } else if p.2.starts_with("float3") {
             108.0
         } else if p.2.starts_with("slider") {
@@ -580,6 +588,14 @@ impl ParametersBg {
                 c.set_rect(r.0, r.1, r.2, r.3);
             }
         }
+        for (i, rp_opt) in self.ramps.iter_mut().enumerate() {
+            if let Some(rp) = rp_opt {
+                let r = rects[i];
+                // Below the 18px label band own_text_labels draws (the ramp
+                // carries no label of its own).
+                rp.set_rect(r.0, r.1 + 18.0, r.2, r.3 - 18.0);
+            }
+        }
     }
 
     /// The legacy `set_rect`/`set_display_params` tail: recompute the content height, clamp the
@@ -680,6 +696,16 @@ impl ParametersBg {
                 if let Some(c) = &self.colors[i] {
                     labels.extend(c.own_text_labels());
                 }
+            } else if ptype == "ramp" {
+                // The name label only — the ramp's own control labels ride its
+                // scene-path paint (paint_scene_rows).
+                labels.push(TextLabel {
+                    text: name.clone(),
+                    x: r.0,
+                    y: r.1,
+                    font_size: 12.0,
+                    color: [0xaa, 0xaa, 0xbb],
+                });
             } else {
                 labels.push(TextLabel {
                     text: format!("{}: {}", name, value),
@@ -695,6 +721,7 @@ impl ParametersBg {
 
     /// The dropdown rows' popover, if one is open — the widget's OWN popover surface
     /// ([`Paint::popover`]); the raw `children`'s popovers are the adapter's recursion.
+    /// The ramp rows' field dropdowns count too.
     fn choices_popover_rect(&self) -> Option<(f32, f32, f32, f32)> {
         for d_opt in &self.choices {
             if let Some(d) = d_opt {
@@ -703,6 +730,18 @@ impl ParametersBg {
                 }
             }
         }
+        for rp_opt in &self.ramps {
+            if let Some(rp) = rp_opt {
+                let ramp = rp.inner();
+                if let Some(r) = ramp
+                    .preset_dropdown
+                    .popover_rect()
+                    .or_else(|| ramp.line_type_dropdown.popover_rect())
+                {
+                    return Some(r);
+                }
+            }
+        }
         None
     }
 
@@ -1099,6 +1138,27 @@ impl ParametersBg {
         out
     }
 
+    /// The scene-path companion to the legacy views: rows whose widgets paint
+    /// prims NO flat tuple view can carry (the ramp rows' curve fill, key
+    /// circles, and field controls). A host rendering this panel through the
+    /// legacy hatches calls this with its own `PaintCtx` inside the pane's
+    /// scroll clip, after the flat chrome — or the rows draw as bare labels.
+    pub fn paint_scene_rows(&self, pc: &mut PaintCtx) {
+        if !self.visible {
+            return;
+        }
+        let hidden = self.hidden_rows();
+        let dummy = UiContext::new();
+        for (i, p) in self.display_params.iter().enumerate() {
+            if hidden[i] || p.2 != "ramp" {
+                continue;
+            }
+            if let Some(rp) = &self.ramps[i] {
+                rp.paint_self(&dummy, pc);
+            }
+        }
+    }
+
 }
 
 impl Layout for ParametersBg {
@@ -1152,6 +1212,7 @@ impl Paint for ParametersBg {
         for (qx, qy, qw, qh, qc) in self.plain_quads() {
             ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
         }
+        self.paint_scene_rows(ctx);
         // Scene-path hosts get the scrollbar on top (the designer instead straddles it around
         // the pane plate through `scrollbar_quads`).
         for (qx, qy, qw, qh, qc) in self.scrollbar_quads() {
@@ -1225,6 +1286,12 @@ impl Paint for ParametersBg {
                 d.render_popover(pc);
             }
         }
+        for rp_opt in &self.ramps {
+            if let Some(rp) = rp_opt {
+                rp.inner().preset_dropdown.render_popover(pc);
+                rp.inner().line_type_dropdown.render_popover(pc);
+            }
+        }
     }
 }
 
@@ -1387,6 +1454,17 @@ impl Input for ParametersBg {
                 }
             }
         }
+        // Ramp rows tick their field widgets (preset application, slider→key
+        // sync) and drain their change flag — fold the curve back into the row
+        // value when it moved.
+        for i in 0..self.ramps.len() {
+            if let Some(rp) = &mut self.ramps[i] {
+                if rp.tick(dt, &mut dummy) {
+                    self.display_params[i].1 = rp.inner().spec_string();
+                    changed = true;
+                }
+            }
+        }
         // Decay the "recently scrolled" window; keep frames coming until it expires so the
         // scrollbar's sink behind the plate actually renders.
         if self.scroll_activity > 0.0 {
@@ -1504,6 +1582,16 @@ impl Input for ParametersBg {
                         }
                     }
                 }
+                // Ramp rows: a move can drag a key — re-serialize the curve
+                // into the row value so hosts polling `node_params` see it.
+                for i in 0..self.ramps.len() {
+                    if let Some(rp) = &mut self.ramps[i] {
+                        if rp.on_cursor_moved(px, py, ui) {
+                            self.display_params[i].1 = rp.inner().spec_string();
+                            changed = true;
+                        }
+                    }
+                }
 
                 changed
             }
@@ -1606,6 +1694,23 @@ impl Input for ParametersBg {
                         }
                     }
                 }
+                // The ramp rows' field dropdowns can pop over neighboring rows too.
+                for (i, rp_opt) in self.ramps.iter_mut().enumerate() {
+                    if hidden[i] {
+                        continue;
+                    }
+                    if let Some(rp) = rp_opt {
+                        let ramp = rp.inner();
+                        if ramp.preset_dropdown.popover_rect().is_some()
+                            || ramp.line_type_dropdown.popover_rect().is_some()
+                        {
+                            if rp.mouse_input(button, state, px, py, ui) {
+                                self.display_params[i].1 = rp.inner().spec_string();
+                                return true;
+                            }
+                        }
+                    }
+                }
 
                 // 2. Propagate to our widgets
                 for (i, p) in self.display_params.iter_mut().enumerate() {
@@ -1691,6 +1796,13 @@ impl Input for ParametersBg {
                                 return true;
                             }
                         }
+                    } else if p.2 == "ramp" {
+                        if let Some(rp) = &mut self.ramps[i] {
+                            if rp.mouse_input(button, state, px, py, ui) {
+                                p.1 = rp.inner().spec_string();
+                                return true;
+                            }
+                        }
                     }
                 }
 
@@ -2240,6 +2352,15 @@ impl ParamController for ParametersBg {
                     None
                 }
             }).collect();
+            self.ramps = self.display_params.iter().map(|p| {
+                if p.2 == "ramp" {
+                    let mut rp = Ramp::new();
+                    rp.inner_mut().set_spec(&p.1);
+                    Some(rp)
+                } else {
+                    None
+                }
+            }).collect();
         } else {
             for (i, p_new) in params.iter().enumerate() {
                 if Some(i) != self.focused_param && Some(i) != self.dragging_param {
@@ -2286,6 +2407,10 @@ impl ParamController for ParametersBg {
                         if !c.editing {
                             c.set_value_string(&p_new.1);
                         }
+                    } else if let Some(ref mut rp) = self.ramps[i] {
+                        if !rp.inner().is_dragging_key {
+                            rp.set_value_string(&p_new.1);
+                        }
                     }
                 }
             }
@@ -2387,6 +2512,24 @@ mod tests {
         );
     }
 
+    #[test]
+    fn ramp_row_builds_from_spec_and_edits_serialize_back() {
+        let mut ctx = UiContext::new();
+        let mut p = panel_with(&[("Bevel Profile", "smooth;0.000:0.000,1.000:1.000", "ramp")]);
+        let rp = p.ramps[0].as_ref().expect("ramp row builds a Ramp");
+        assert_eq!(rp.inner().keys.len(), 2);
+        assert!(rp.inner().smooth());
+
+        // A press inside the curve area adds a key, and the row value carries
+        // the re-serialized spec (what hosts poll and persist).
+        let (rx, ry, rw, _) = rp.rect();
+        p.mouse_input(MouseButton::Left, ElementState::Pressed, rx + rw * 0.5, ry + 40.0, &mut ctx);
+        assert_eq!(p.ramps[0].as_ref().unwrap().inner().keys.len(), 3);
+        let val = &ParamController::node_params(&*p)[0].1;
+        assert_eq!(val.split(',').count(), 3, "spec re-serialized: {val}");
+        assert!(val.starts_with("smooth;"));
+    }
+
     #[test]
     fn toggle_click_commits_value_and_unfocus_commits_editor() {
         let mut ctx = UiContext::new();
diff --git a/src/widget/input/mod.rs b/src/widget/input/mod.rs
index 0c7115e..7aa654e 100644
--- a/src/widget/input/mod.rs
+++ b/src/widget/input/mod.rs
@@ -22,7 +22,7 @@ pub use trackpad::{Trackpad, Finger};
 pub use font_selector::FontSelector;
 pub use button_strip::ButtonStrip;
 pub use keybind_recorder::KeybindRecorder;
-pub use ramp::{Ramp, RampKey, ColorRamp, ColorRampKey};
+pub use ramp::{Ramp, RampKey, ColorRamp, ColorRampKey, format_ramp_spec, parse_ramp_spec};
 
 pub const BREADCRUMB_PADDING: f32 = 8.0;
 pub const SEGMENT_GAP: f32 = 4.0;
diff --git a/src/widget/input/ramp.rs b/src/widget/input/ramp.rs
index fecbf42..39d8644 100644
--- a/src/widget/input/ramp.rs
+++ b/src/widget/input/ramp.rs
@@ -241,7 +241,7 @@ impl Ramp {
         }
         self.keys[0].value
     }
-    
+
     fn sort_keys(&mut self) {
         let prev_selected_id = self.selected_key_idx.map(|idx| self.keys[idx].pos);
         self.keys.sort_by(|a, b| a.pos.partial_cmp(&b.pos).unwrap());
@@ -251,6 +251,73 @@ impl Ramp {
             }
         }
     }
+
+    /// Whether segments blend with smoothstep (the Bezier line type) vs linearly.
+    pub fn smooth(&self) -> bool {
+        self.line_type_dropdown.selected == 1
+    }
+
+    /// This ramp's state as the DE's ramp spec string ([`format_ramp_spec`]).
+    pub fn spec_string(&self) -> String {
+        let keys: Vec<(f32, f32)> = self.keys.iter().map(|k| (k.pos, k.value)).collect();
+        format_ramp_spec(&keys, self.smooth())
+    }
+
+    /// Apply a spec string ([`parse_ramp_spec`]); returns whether anything changed.
+    /// Unparsable specs are ignored (keeps the current curve).
+    pub fn set_spec(&mut self, spec: &str) -> bool {
+        let Some((keys, smooth)) = parse_ramp_spec(spec) else {
+            return false;
+        };
+        let new_keys: Vec<RampKey> =
+            keys.into_iter().map(|(pos, value)| RampKey { pos, value }).collect();
+        let new_line = if smooth { 1 } else { 0 };
+        let changed = self.line_type_dropdown.selected != new_line
+            || self.keys.len() != new_keys.len()
+            || self
+                .keys
+                .iter()
+                .zip(new_keys.iter())
+                .any(|(a, b)| (a.pos - b.pos).abs() > 0.0005 || (a.value - b.value).abs() > 0.0005);
+        if changed {
+            self.keys = new_keys;
+            self.line_type_dropdown.selected = new_line;
+            self.selected_key_idx = None;
+            self.preset_dropdown.selected = 0; // Custom
+            self.arrange_fields();
+        }
+        changed
+    }
+}
+
+/// Serialize ramp keys + line type as the DE's ramp spec string:
+/// `"smooth;0.000:0.500,0.200:1.000,…"` (`"linear;…"` for straight segments) —
+/// the format ramp-valued params travel in (`ParametersBg` "ramp" rows,
+/// project files, `cce_ui::layout::set_bevel_profile_keys` consumers).
+pub fn format_ramp_spec(keys: &[(f32, f32)], smooth: bool) -> String {
+    let body: Vec<String> =
+        keys.iter().map(|(p, v)| format!("{:.3}:{:.3}", p, v)).collect();
+    format!("{};{}", if smooth { "smooth" } else { "linear" }, body.join(","))
+}
+
+/// Parse a ramp spec string ([`format_ramp_spec`]) into `(keys, smooth)`.
+/// `None` for anything that doesn't yield at least two keys.
+pub fn parse_ramp_spec(spec: &str) -> Option<(Vec<(f32, f32)>, bool)> {
+    let (head, body) = spec.split_once(';')?;
+    let smooth = head.trim() == "smooth";
+    let mut keys = Vec::new();
+    for part in body.split(',') {
+        let (p, v) = part.split_once(':')?;
+        keys.push((
+            p.trim().parse::<f32>().ok()?.clamp(0.0, 1.0),
+            v.trim().parse::<f32>().ok()?.clamp(0.0, 1.0),
+        ));
+    }
+    if keys.len() < 2 {
+        return None;
+    }
+    keys.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
+    Some((keys, smooth))
 }
 
 
@@ -947,6 +1014,16 @@ impl Input for Ramp {
         true
     }
 
+    /// The curve as a ramp spec string ([`format_ramp_spec`]) — the value hosts
+    /// poll and persist for ramp-valued params.
+    fn value_string(&self) -> Option<String> {
+        Some(self.spec_string())
+    }
+
+    fn set_value_string(&mut self, val: &str) -> bool {
+        self.set_spec(val)
+    }
+
     /// The open dropdown popover extends the hit area (the 5p Dropdown pattern).
     fn hit(&self, rect: Rect, x: f32, y: f32) -> bool {
         if let Some((px, py, pw, ph)) = {
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 6958799..1241ace 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -519,7 +519,8 @@ pub use self::core::focus::link_parent_child;
 pub use self::input::{
     Button, TextBox, Spinbox, Dropdown, Checkbox, Toggle, Slider, RangeSlider,
     ColorSelector, Finger, Trackpad, get_font_db, ActiveThumb, FontSelector,
-    ButtonStrip, KeybindRecorder, Ramp, RampKey, ColorRamp, ColorRampKey
+    ButtonStrip, KeybindRecorder, Ramp, RampKey, ColorRamp, ColorRampKey,
+    format_ramp_spec, parse_ramp_spec
 };
 pub use self::container::{
     ContainerLayout, OverlayLayout, ManualLayout, VerticalLayout, GridLayout, AdaptiveGridLayout,