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

commite15634800ddc90d87721f523dea77a71ef9bdb14
parent21b04d4195
authorLucas Galante <[email protected]>
date2026-08-03 11:59
feat: cce-bevel shows lit cross-sections shaped by sliders, not ramps

Each profile renders as a cutaway of the actual edge — material slab in
the plate color inside a dark opening, surface stroked with per-segment
lighting from the DE azimuth; the carve section runs plateau→wall→floor,
the roll ends at the silhouette's cut face. Shape comes from three
semantic knobs (Shoulder / Base / Bias) through the rational
two-exponent ease h(w)=w^a/(w^a+(1-w)^b) over a bias pre-warp —
midpoints reproduce the analytic smoothstep, extremes give sharp
chamfers or wide round-overs. Curves sample into 17-key linear ramp
specs, so the config format and DE-wide loader are untouched and
free-form specs from cce-designer still load; knob triples persist as
style.surface.relief.profile_knobs / edge_knobs so the editor reopens
where it left off. Untouched sections save the identity sentinel
(analytic). The overflow rim is gone — no key pegs to spill.

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

 src/bin/cce-bevel.rs | 468 ++++++++++++++++++++++++++++++++++-----------------
 src/layout.rs        |   4 +
 2 files changed, 322 insertions(+), 150 deletions(-)

diff --git a/src/bin/cce-bevel.rs b/src/bin/cce-bevel.rs
index 5dd1945..4e255d4 100644
--- a/src/bin/cce-bevel.rs
+++ b/src/bin/cce-bevel.rs
@@ -1,29 +1,30 @@
-//! `cce-bevel` — the relief-material control interface: two [`Ramp`] editors
-//! shaping the DE's bevel profiles (the wall curve every recess/boss carve
-//! renders, and the plate perimeter's roll curve), with the companion
-//! depth/width knobs. Every edit applies live to this process — the popup's
-//! own plate, wells, and buttons ARE the preview — and logs to stdout;
-//! Save persists to `~/.config/cce/config.kdl` (`style.surface.relief`) so
-//! every cce app starts with the styled walls.
+//! `cce-bevel` — the relief-material control interface. Each profile (the
+//! wall curve every recess/boss carve renders, and the plate perimeter's
+//! roll) is shown as a lit CROSS-SECTION of the actual edge — plateau, wall,
+//! floor — and shaped by three semantic sliders (Shoulder / Base / Bias)
+//! instead of a free-form ramp. Every edit applies live to this process (the
+//! popup's own plate, wells, and buttons ARE the preview) and logs the
+//! sampled spec to stdout; Save persists to `~/.config/cce/config.kdl`
+//! (`style.surface.relief`) so every cce app starts with the material.
 //!
-//! Architecture mirrors `cce-ramp` (single-purpose popup) with the DemoApp
-//! multi-root event routing.
+//! The curve family is the two-exponent rational ease
+//! `h(w) = w^a / (w^a + (1-w)^b)` over a bias pre-warp `w = v^g` — monotone,
+//! endpoint-exact, with the shoulder (a) and base fillet (b) shaped
+//! independently. Slider midpoints give a=b=2, g=1: the analytic smoothstep.
+//! Curves are sampled into ramp-spec keys, so the config format and the
+//! DE-wide loader are unchanged — free-form specs from cce-designer or a
+//! hand-edited config still load everywhere.
 
 use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
 use cce_ui::layout::RELIEF_PROFILE_IDENTITY_SPEC as IDENTITY_SPEC;
 use cce_ui::scene::layout::Rect;
-use cce_ui::scene::paint::{DisplayList, PaintCtx};
+use cce_ui::scene::paint::{Cap, DisplayList, PaintCtx};
 use cce_ui::widget::{
-    Adapted, Button, ElementState, Event, KeyEvent, MouseButton, MouseScrollDelta, Ramp, Slider,
+    Adapted, Button, ElementState, Event, KeyEvent, MouseButton, MouseScrollDelta, Slider,
     WidgetHost, WidgetId,
 };
 use wayland_client::QueueHandle;
 
-/// Transparent rim between the surface edge and the plate: room for the
-/// ramps' key pegs to render outside the window frame instead of being
-/// clipped at the buffer edge.
-const OVERFLOW_MARGIN: f32 = 40.0;
-
 const HEADER_FONT_SIZE: f32 = 13.0;
 const HEADER_COLOR: [u8; 3] = [0x9a, 0x9a, 0xa4];
 
@@ -33,25 +34,105 @@ const HEADER_COLOR: [u8; 3] = [0x9a, 0x9a, 0xa4];
 const DEPTH_RANGE: (f32, f32) = (0.0, 0.6);
 const WIDTH_RANGE: (f32, f32) = (2.0, 24.0);
 
+/// Sample count for the spec written to config — enough that the 32-slot
+/// renderer LUT sees the curve, few enough that the config line stays sane.
+const SPEC_SAMPLES: usize = 17;
+
 #[derive(Debug, Clone)]
 enum BevelMsg {
     Exit,
 }
 
+/// One profile section's shape state: the three knob sliders plus whether
+/// the profile has diverged from the analytic default.
+struct ProfileKnobs {
+    shoulder: Adapted<Slider>,
+    base: Adapted<Slider>,
+    bias: Adapted<Slider>,
+    /// False until a knob moves (or config carried saved knobs): the DE
+    /// renders its analytic profile and Save writes the identity sentinel.
+    custom: bool,
+    /// Last spec applied+logged.
+    last_spec: String,
+}
+
+impl ProfileKnobs {
+    fn new(seed: Option<(f32, f32, f32)>) -> Self {
+        let (s, b, c) = seed.unwrap_or((0.5, 0.5, 0.5));
+        let knob = |v: f32, label: &str| {
+            Slider::new().with_label(label).with_value(v.clamp(0.0, 1.0)).with_scroll(true)
+        };
+        let mut this = Self {
+            shoulder: knob(s, "Shoulder"),
+            base: knob(b, "Base"),
+            bias: knob(c, "Bias"),
+            custom: seed.is_some(),
+            last_spec: String::new(),
+        };
+        this.last_spec = if this.custom { this.spec() } else { IDENTITY_SPEC.to_string() };
+        this
+    }
+
+    fn values(&self) -> (f32, f32, f32) {
+        (self.shoulder.inner().value(), self.base.inner().value(), self.bias.inner().value())
+    }
+
+    /// The section's height curve `h(v)`: bias pre-warp, then the rational
+    /// two-exponent ease. Exponents run 0.5 (sharp crease) → 2 (smoothstep,
+    /// the midpoint) → 8 (wide round-over); bias skews the drop early/late.
+    fn eval(&self, v: f32) -> f32 {
+        let (s, b, c) = self.values();
+        let a = 2.0 * 4f32.powf(2.0 * s - 1.0);
+        let be = 2.0 * 4f32.powf(2.0 * b - 1.0);
+        let g = 4f32.powf(2.0 * c - 1.0);
+        let w = v.clamp(0.0, 1.0).powf(g);
+        let num = w.powf(a);
+        let den = num + (1.0 - w).powf(be);
+        if den <= f32::EPSILON {
+            return if w > 0.5 { 1.0 } else { 0.0 };
+        }
+        (num / den).clamp(0.0, 1.0)
+    }
+
+    /// The curve sampled as linear ramp-spec keys — what the renderer LUT
+    /// and the config carry.
+    fn keys(&self) -> Vec<(f32, f32)> {
+        (0..SPEC_SAMPLES)
+            .map(|i| {
+                let v = i as f32 / (SPEC_SAMPLES - 1) as f32;
+                (v, self.eval(v))
+            })
+            .collect()
+    }
+
+    fn spec(&self) -> String {
+        cce_ui::widget::format_ramp_spec(&self.keys(), false)
+    }
+
+    fn take_change(&mut self) -> bool {
+        // Bitwise-or on purpose: every slider's flag must drain.
+        self.shoulder.take_change() | self.base.take_change() | self.bias.take_change()
+    }
+
+    fn set_defaults(&mut self) {
+        self.shoulder.set_value(0.5);
+        self.base.set_value(0.5);
+        self.bias.set_value(0.5);
+        self.custom = false;
+        self.last_spec = IDENTITY_SPEC.to_string();
+    }
+}
+
 struct BevelPopup {
-    /// The carve wall curve — what `carve_slope` renders on every
+    /// The carve wall — what `carve_slope` renders on every
     /// recess/boss/ridge in the DE.
-    wall_ramp: Adapted<Ramp>,
-    /// The plate perimeter roll curve — `roll_slope`'s descent profile.
-    edge_ramp: Adapted<Ramp>,
+    wall: ProfileKnobs,
+    /// The plate perimeter roll — `roll_slope`'s descent profile.
+    edge: ProfileKnobs,
     depth_slider: Adapted<Slider>,
     width_slider: Adapted<Slider>,
     save_button: Adapted<Button>,
     reset_button: Adapted<Button>,
-    /// Last specs applied+logged — edits are detected by comparison, so
-    /// tick-driven changes (hover-scroll glides) apply too.
-    last_wall: String,
-    last_edge: String,
     /// Status line under the buttons: what the last save/reset did.
     status: String,
     ui_context: cce_ui::context::UiContext,
@@ -63,13 +144,118 @@ struct BevelPopup {
     wall_header: (f32, f32),
     edge_header: (f32, f32),
     status_pos: (f32, f32),
+    wall_rect: Rect,
+    edge_rect: Rect,
+}
+
+/// Parse a saved "shoulder,base,bias" knob triple.
+fn parse_knobs(s: &str) -> Option<(f32, f32, f32)> {
+    let mut it = s.split(',').map(|p| p.trim().parse::<f32>());
+    match (it.next(), it.next(), it.next()) {
+        (Some(Ok(a)), Some(Ok(b)), Some(Ok(c))) => {
+            Some((a.clamp(0.0, 1.0), b.clamp(0.0, 1.0), c.clamp(0.0, 1.0)))
+        }
+        _ => None,
+    }
+}
+
+/// Draw one profile as a lit cutaway: the material slab (plate color) inside
+/// a dark opening, its surface stroked with segment lighting from the DE's
+/// light azimuth. `has_floor` distinguishes the carve (wall meets a floor
+/// inside the material) from the roll (the surface drops to the silhouette
+/// and the material simply ends — air beyond the edge).
+fn draw_section(pc: &mut PaintCtx, rect: Rect, profile: &ProfileKnobs, has_floor: bool) {
+    let radius = 6.0f32;
+    let radii = (radius, radius, radius, radius);
+    pc.rounded_rect(rect, radius, (true, true, true, true), [0.08, 0.08, 0.10, 1.0]);
+
+    // The section geometry: plateau band, then the wall over `wall_w`, then
+    // (carve only) the floor band. Vertical span is the feature depth.
+    let m = 12.0f32;
+    let y_top = rect.y + 16.0;
+    let y_bot = rect.y + rect.height - 22.0;
+    let drop = y_bot - y_top;
+    let x_l = rect.x + m;
+    let x_r = rect.x + rect.width - m;
+    let plateau_w = (x_r - x_l) * 0.16;
+    let wall_w = (x_r - x_l) * if has_floor { 0.56 } else { 0.68 };
+    let x0 = x_l + plateau_w;
+    let x1 = x0 + wall_w;
+
+    // Surface height at a section x.
+    let surface_y = |x: f32| -> Option<f32> {
+        if x <= x0 {
+            Some(y_top)
+        } else if x <= x1 {
+            Some(y_top + profile.eval((x - x0) / wall_w) * drop)
+        } else if has_floor {
+            Some(y_bot)
+        } else {
+            None // past the silhouette: air
+        }
+    };
+
+    // The slab: the plate material itself, filled from the surface down to
+    // the cut's bottom edge. Columns share exact edges (opaque fill, but the
+    // ramp-fill rule keeps seams clean under AA).
+    let mut slab = cce_ui::color::page_low_color();
+    slab = [slab[0] * 1.25 + 0.03, slab[1] * 1.25 + 0.03, slab[2] * 1.25 + 0.03, 1.0];
+    let slab_bot = rect.y + rect.height - 10.0;
+    let step = 2.0f32;
+    let mut x = x_l;
+    while x < x_r {
+        let xm = (x + step / 2.0).min(x_r);
+        if let Some(sy) = surface_y(xm) {
+            let w = step.min(x_r - x);
+            pc.quad(Rect { x, y: sy, width: w, height: (slab_bot - sy).max(0.0) }, slab);
+        }
+        x += step;
+    }
+
+    // The surface stroke, lit per segment: outward normal (material below)
+    // against the DE light azimuth — the same light the real walls shade by.
+    let az = cce_ui::layout::light_source_position();
+    let (lx, ly) = (az.cos(), -az.sin());
+    let base = [0.60f32, 0.65, 0.74];
+    let n_seg = 56usize;
+    let seg_end = if has_floor { x_r } else { x1 };
+    let mut prev = (x_l, surface_y(x_l).unwrap_or(y_top));
+    for i in 1..=n_seg {
+        let x = x_l + (seg_end - x_l) * i as f32 / n_seg as f32;
+        let Some(y) = surface_y(x) else { break };
+        let (dx, dy) = (x - prev.0, y - prev.1);
+        let len = (dx * dx + dy * dy).sqrt().max(1e-3);
+        let (nx, ny) = (dy / len, -dx / len);
+        let lit = (nx * lx + ny * ly) * 0.35;
+        let c = [
+            (base[0] + lit).clamp(0.0, 1.0),
+            (base[1] + lit).clamp(0.0, 1.0),
+            (base[2] + lit).clamp(0.0, 1.0),
+            1.0,
+        ];
+        pc.vector(prev.0, prev.1, x, y, 2.5, c, Cap::Round);
+        prev = (x, y);
+    }
+    // The roll's cut face: a dimmer vertical edge closing the slab at the
+    // silhouette.
+    if !has_floor {
+        pc.vector(x1, y_bot, x1, slab_bot, 2.0, [0.36, 0.39, 0.46, 1.0], Cap::Round);
+    }
+
+    // The opening's rim, drawn last so its shading falls over the slab edges.
+    let depth = cce_ui::layout::bevel_width().min(rect.height * 0.2);
+    pc.recess(rect, radii, depth);
 }
 
 impl BevelPopup {
-    fn root_ids(&self) -> [WidgetId; 6] {
+    fn root_ids(&self) -> [WidgetId; 10] {
         [
-            self.wall_ramp.id(),
-            self.edge_ramp.id(),
+            self.wall.shoulder.id(),
+            self.wall.base.id(),
+            self.wall.bias.id(),
+            self.edge.shoulder.id(),
+            self.edge.base.id(),
+            self.edge.bias.id(),
             self.depth_slider.id(),
             self.width_slider.id(),
             self.save_button.id(),
@@ -77,10 +263,14 @@ impl BevelPopup {
         ]
     }
 
-    fn roots(&mut self) -> [*mut (dyn WidgetHost + 'static); 6] {
+    fn roots(&mut self) -> [*mut (dyn WidgetHost + 'static); 10] {
         [
-            self.wall_ramp.as_ptr_mut(),
-            self.edge_ramp.as_ptr_mut(),
+            self.wall.shoulder.as_ptr_mut(),
+            self.wall.base.as_ptr_mut(),
+            self.wall.bias.as_ptr_mut(),
+            self.edge.shoulder.as_ptr_mut(),
+            self.edge.base.as_ptr_mut(),
+            self.edge.bias.as_ptr_mut(),
             self.depth_slider.as_ptr_mut(),
             self.width_slider.as_ptr_mut(),
             self.save_button.as_ptr_mut(),
@@ -88,35 +278,25 @@ impl BevelPopup {
         ]
     }
 
-    /// Install a ramp's curve as the live wall (or roll) profile. The
-    /// identity-smooth spec is the "analytic" sentinel ([`IDENTITY_SPEC`]):
-    /// it clears back to the built-in profile instead of installing.
-    fn apply_profile(ramp: &Ramp, spec: &str, roll: bool) {
-        let keys: Vec<(f32, f32)> = ramp.keys.iter().map(|k| (k.pos, k.value)).collect();
-        let smooth = ramp.smooth();
-        match (spec == IDENTITY_SPEC, roll) {
-            (true, true) => cce_ui::layout::clear_roll_profile(),
-            (true, false) => cce_ui::layout::clear_bevel_profile(),
-            (false, true) => cce_ui::layout::set_roll_profile_keys(&keys, smooth),
-            (false, false) => cce_ui::layout::set_bevel_profile_keys(&keys, smooth),
-        }
-    }
-
     /// `take_*` plumbing after any routed dispatch — state-gated, so it does
     /// not matter which propagate call consumed the event.
     fn drain_widget_changes(&mut self) {
-        let wall_spec = self.wall_ramp.inner().spec_string();
-        if wall_spec != self.last_wall {
-            Self::apply_profile(self.wall_ramp.inner(), &wall_spec, false);
-            println!("wall {wall_spec}");
-            self.last_wall = wall_spec;
+        if self.wall.take_change() {
+            self.wall.custom = true;
+            let keys = self.wall.keys();
+            cce_ui::layout::set_bevel_profile_keys(&keys, false);
+            let spec = self.wall.spec();
+            println!("wall {spec}");
+            self.wall.last_spec = spec;
             self.needs_rebuild = true;
         }
-        let edge_spec = self.edge_ramp.inner().spec_string();
-        if edge_spec != self.last_edge {
-            Self::apply_profile(self.edge_ramp.inner(), &edge_spec, true);
-            println!("edge {edge_spec}");
-            self.last_edge = edge_spec;
+        if self.edge.take_change() {
+            self.edge.custom = true;
+            let keys = self.edge.keys();
+            cce_ui::layout::set_roll_profile_keys(&keys, false);
+            let spec = self.edge.spec();
+            println!("edge {spec}");
+            self.edge.last_spec = spec;
             self.needs_rebuild = true;
         }
         if self.depth_slider.take_change() {
@@ -147,21 +327,26 @@ impl BevelPopup {
 
     /// Persist the current material to the shared config
     /// (`style.surface.relief` — the same keys every app reads at startup).
-    /// Identity specs are written as-is; the loader reads them as analytic.
+    /// Untouched sections write the identity sentinel (= analytic); the knob
+    /// triples ride along so this editor reopens where you left it.
     fn save_to_config(&mut self) {
         let path = cce_ui::config::get_config_path();
         let p = path.to_string_lossy().into_owned();
         let depth = format!("{:.3}", self.depth_slider.inner().get_scaled_value());
         let width = format!("{:.2}", self.width_slider.inner().get_scaled_value());
-        let ok = cce_ui::config::write_config_value(&p, "style.surface.relief.depth", &depth, "style")
-            & cce_ui::config::write_config_value(&p, "style.surface.relief.width", &width, "style")
-            & cce_ui::config::write_config_value(&p, "style.surface.relief.profile", &self.last_wall, "style")
-            & cce_ui::config::write_config_value(
-                &p,
-                "style.surface.relief.edge_profile",
-                &self.last_edge,
-                "style",
-            );
+        let knob_str = |k: &ProfileKnobs| {
+            let (s, b, c) = k.values();
+            format!("{s:.3},{b:.3},{c:.3}")
+        };
+        let w = &mut |key: &str, value: &str| {
+            cce_ui::config::write_config_value(&p, key, value, "style")
+        };
+        let ok = w("style.surface.relief.depth", &depth)
+            & w("style.surface.relief.width", &width)
+            & w("style.surface.relief.profile", &self.wall.last_spec)
+            & w("style.surface.relief.edge_profile", &self.edge.last_spec)
+            & w("style.surface.relief.profile_knobs", &knob_str(&self.wall))
+            & w("style.surface.relief.edge_knobs", &knob_str(&self.edge));
         self.status = if ok {
             println!("saved {p}");
             "Saved — apps pick the material up on start.".to_string()
@@ -170,12 +355,13 @@ impl BevelPopup {
         };
     }
 
-    /// Back to the analytic material, live only (Save persists it): identity
-    /// curves on both ramps, default depth/width.
+    /// Back to the analytic material, live only (Save persists it): knobs to
+    /// their midpoints, both profiles cleared, default depth/width.
     fn reset_live(&mut self) {
-        self.wall_ramp.set_spec(IDENTITY_SPEC);
-        self.edge_ramp.set_spec(IDENTITY_SPEC);
-        // drain_widget_changes sees the spec change and clears the profiles.
+        self.wall.set_defaults();
+        self.edge.set_defaults();
+        cce_ui::layout::clear_bevel_profile();
+        cce_ui::layout::clear_roll_profile();
         if let Ok(mut reg) = cce_ui::layout::get_style_registry().write() {
             reg.set_float("bevel_depth", 0.15);
             reg.set_float("bevel_width", 9.3);
@@ -197,34 +383,25 @@ impl Application for BevelPopup {
         _sender: calloop::channel::Sender<Self::Message>,
     ) -> Self {
         cce_ui::scale::set_scale_factor(1.0);
-        // Force the lazy config load BEFORE reading the registry: the specs
-        // are read directly (no getter wraps them), so nothing else has
-        // triggered it yet this early in startup.
+        // Force the lazy config load BEFORE reading the registry: the knob
+        // strings are read directly (no getter wraps them), so nothing else
+        // has triggered it yet this early in startup.
         cce_ui::layout::lazy_init_style_registry();
-        // Seed the ramps from the configured material (reload_config has
-        // already installed the live profiles); identity when unconfigured.
-        let reg = cce_ui::layout::get_style_registry();
-        let (wall_spec, edge_spec) = {
-            let reg = reg.read().unwrap();
+        let (wall_seed, edge_seed) = {
+            let reg = cce_ui::layout::get_style_registry().read().unwrap();
             (
-                reg.get_string("bevel_profile_spec").unwrap_or_else(|| IDENTITY_SPEC.to_string()),
-                reg.get_string("roll_profile_spec").unwrap_or_else(|| IDENTITY_SPEC.to_string()),
+                reg.get_string("bevel_profile_knobs").as_deref().and_then(parse_knobs),
+                reg.get_string("roll_profile_knobs").as_deref().and_then(parse_knobs),
             )
         };
-        let mut wall_ramp = Ramp::new();
-        wall_ramp.set_spec(&wall_spec);
-        let mut edge_ramp = Ramp::new();
-        edge_ramp.set_spec(&edge_spec);
-        let last_wall = wall_ramp.inner().spec_string();
-        let last_edge = edge_ramp.inner().spec_string();
 
         let depth = cce_ui::layout::bevel_depth();
         let width = cce_ui::layout::bevel_width();
         let (dmin, dmax) = DEPTH_RANGE;
         let (wmin, wmax) = WIDTH_RANGE;
         Self {
-            wall_ramp,
-            edge_ramp,
+            wall: ProfileKnobs::new(wall_seed),
+            edge: ProfileKnobs::new(edge_seed),
             depth_slider: Slider::new()
                 .with_label("Depth")
                 .with_range(dmin, dmax)
@@ -241,35 +418,29 @@ impl Application for BevelPopup {
                 .with_scroll(true),
             save_button: Button::new(0.0, 0.0, 0.0, 0.0).with_label("Save"),
             reset_button: Button::new(0.0, 0.0, 0.0, 0.0).with_label("Reset"),
-            last_wall,
-            last_edge,
             status: "Edits apply live; Save writes config.kdl.".to_string(),
             ui_context: cce_ui::context::UiContext::new(),
-            width: 620,
-            height: 1020,
+            width: 520,
+            height: 620,
             scale_factor: 1.0,
             needs_rebuild: true,
             registered: false,
             wall_header: (0.0, 0.0),
             edge_header: (0.0, 0.0),
             status_pos: (0.0, 0.0),
+            wall_rect: Rect::ZERO,
+            edge_rect: Rect::ZERO,
         }
     }
 
-    // Buffer-larger-than-geometry mode (the cce-ramp idiom): key pegs painted
-    // on the rim render outside the window frame; rim clicks fall through.
-    fn overflow_margin(&self) -> u32 {
-        OVERFLOW_MARGIN as u32
-    }
-
     fn settings(&self) -> WindowSettings {
         WindowSettings {
             title: "Bevel".to_string(),
             app_id: "cce-bevel".to_string(),
-            width: 540,
-            height: 940,
+            width: 520,
+            height: 620,
             fullscreen: false,
-            min_size: Some((460, 760)),
+            min_size: Some((440, 540)),
         }
     }
 
@@ -281,7 +452,6 @@ impl Application for BevelPopup {
 
     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
         if self.ui_context.tick(dt) {
-            // Tick-driven edits (hover-scroll glide) apply and log too.
             self.drain_widget_changes();
             *needs_rebuild = true;
             self.needs_rebuild = true;
@@ -309,28 +479,44 @@ impl Application for BevelPopup {
             self.scale_factor = scale;
             cce_ui::scale::set_scale_factor(scale as f32);
 
-            // Manual column layout at the popup pad (the cce-ramp idiom).
-            let pad = OVERFLOW_MARGIN + cce_ui::layout::backplate_padding() / 2.0;
+            // Manual column layout: per section a header, the cutaway, and
+            // the three knobs on one row; then the global rows.
+            let pad = cce_ui::layout::backplate_padding();
             let x = pad;
             let w = (self.width as f32 - 2.0 * pad).max(0.0);
             let header_h = HEADER_FONT_SIZE + 7.0;
-            let gap = 16.0;
-            let knob_h = 22.0 + Ramp::label_strip();
+            let gap = 14.0;
+            let strip = {
+                let (_, fsize) = cce_ui::layout::control_label_font_detached_parsed();
+                fsize + cce_ui::layout::control_label_margin()
+            };
+            let knob_h = 22.0 + strip;
             let button_h = 26.0;
             let status_h = HEADER_FONT_SIZE + 4.0;
-            let fixed = 2.0 * header_h + 3.0 * gap + knob_h + gap + button_h + status_h + 8.0;
-            let ramp_h =
-                ((self.height as f32 - 2.0 * pad - fixed) / 2.0).max(220.0);
+            let fixed = 2.0 * (header_h + gap + knob_h + gap) + knob_h + gap + button_h + 8.0
+                + status_h;
+            let cut_h = ((self.height as f32 - 2.0 * pad - fixed) / 2.0).clamp(90.0, 200.0);
+
+            let kw = (w - 2.0 * gap) / 3.0;
+            let knob_row = |k: &mut ProfileKnobs, x: f32, y: f32| {
+                k.shoulder.set_rect(x, y, kw, knob_h);
+                k.base.set_rect(x + kw + gap, y, kw, knob_h);
+                k.bias.set_rect(x + 2.0 * (kw + gap), y, kw, knob_h);
+            };
 
             let mut y = pad;
             self.wall_header = (x, y);
             y += header_h;
-            self.wall_ramp.set_rect(x, y, w, ramp_h);
-            y += ramp_h + gap;
+            self.wall_rect = Rect { x, y, width: w, height: cut_h };
+            y += cut_h + gap;
+            knob_row(&mut self.wall, x, y);
+            y += knob_h + gap;
             self.edge_header = (x, y);
             y += header_h;
-            self.edge_ramp.set_rect(x, y, w, ramp_h);
-            y += ramp_h + gap;
+            self.edge_rect = Rect { x, y, width: w, height: cut_h };
+            y += cut_h + gap;
+            knob_row(&mut self.edge, x, y);
+            y += knob_h + gap;
             let half = (w - gap) / 2.0;
             self.depth_slider.set_rect(x, y, half, knob_h);
             self.width_slider.set_rect(x + half + gap, y, half, knob_h);
@@ -344,29 +530,20 @@ impl Application for BevelPopup {
             self.ui_context.rebuild_spatial_grid();
         }
 
-        self.ui_context.clear_popovers();
-        if self.wall_ramp.popover_rect().is_some() {
-            self.ui_context.register_popover(&mut self.wall_ramp);
-        }
-        if self.edge_ramp.popover_rect().is_some() {
-            self.ui_context.register_popover(&mut self.edge_ramp);
-        }
-
         let mut pc = PaintCtx::new();
         let (w, h) = (self.width as f32, self.height as f32);
 
-        // The window plate at FULL opacity on purpose: its rolled perimeter is
-        // the edge profile's preview, and the wells/buttons below preview the
-        // wall profile — the popup is its own material sample.
+        // The window plate at full opacity on purpose: its rolled perimeter
+        // previews the edge profile, and the wells/buttons preview the wall
+        // profile — the popup is its own material sample.
         let mut plate = cce_ui::color::page_low_color();
         if plate[3] > 0.001 {
             plate[3] = cce_ui::color::active_backplate_opacity();
         }
         let radius = cce_ui::colors::backplate_corner_radius();
         let bevel = cce_ui::layout::bevel_width();
-        let m = OVERFLOW_MARGIN;
         pc.plate(
-            Rect { x: m, y: m, width: w - 2.0 * m, height: h - 2.0 * m },
+            Rect { x: 0.0, y: 0.0, width: w, height: h },
             (radius, radius, radius, radius),
             plate,
             bevel,
@@ -387,34 +564,25 @@ impl Application for BevelPopup {
         header(&mut pc, "Edge profile — plate perimeter roll", self.edge_header);
         header(&mut pc, &self.status, self.status_pos);
 
-        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.wall_ramp, &mut pc);
-        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.edge_ramp, &mut pc);
-        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.depth_slider, &mut pc);
-        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.width_slider, &mut pc);
+        draw_section(&mut pc, self.wall_rect, &self.wall, true);
+        draw_section(&mut pc, self.edge_rect, &self.edge, false);
+
+        for s in [
+            &self.wall.shoulder,
+            &self.wall.base,
+            &self.wall.bias,
+            &self.edge.shoulder,
+            &self.edge.base,
+            &self.edge.bias,
+            &self.depth_slider,
+            &self.width_slider,
+        ] {
+            cce_ui::scene::painter::paint_root_into(&self.ui_context, s, &mut pc);
+        }
         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.save_button, &mut pc);
         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.reset_button, &mut pc);
 
-        // The ramps' field-dropdown popovers, drawn into the frame on top.
-        for ramp in [&self.wall_ramp, &self.edge_ramp] {
-            let Some((px, py, pw, ph)) = ramp.popover_rect() else { continue };
-            let mut coll = cce_ui::layout::PopoverCollector::new();
-            ramp.inner().preset_dropdown.render_popover(&mut coll);
-            ramp.inner().line_type_dropdown.render_popover(&mut coll);
-            for &(c, x, y, qw, qh) in &coll.rects {
-                pc.quad(Rect { x, y, width: qw, height: qh }, c);
-            }
-            let bounds = Some([px, py, px + pw, py + ph]);
-            for (content, size, tx, ty, color, font, _b) in coll.texts {
-                let color_u8 = [
-                    (color[0] * 255.0).clamp(0.0, 255.0) as u8,
-                    (color[1] * 255.0).clamp(0.0, 255.0) as u8,
-                    (color[2] * 255.0).clamp(0.0, 255.0) as u8,
-                ];
-                pc.text_with(content, tx, ty, size, color_u8, font, bounds);
-            }
-        }
-
-        // The shared context menu, last, on top of everything.
+        // The shared context menu (slider Copy/Paste), last, on top.
         if self.ui_context.is_context_menu_visible() {
             for (qx, qy, qw, qh, c) in self.ui_context.context_menu_quads() {
                 pc.quad(Rect { x: qx, y: qy, width: qw, height: qh }, c);
diff --git a/src/layout.rs b/src/layout.rs
index 2d22ee5..55171ba 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -123,6 +123,10 @@ fn flatten_json_to_flat_props(val: &serde_json::Value, prefix: &str, flat_props:
                 // (written by cce-bevel, installed by reload_config).
                 "style.surface.relief.profile" => "bevel_profile_spec",
                 "style.surface.relief.edge_profile" => "roll_profile_spec",
+                // cce-bevel's slider positions behind those specs
+                // ("shoulder,base,bias" — only the editor reads these).
+                "style.surface.relief.profile_knobs" => "bevel_profile_knobs",
+                "style.surface.relief.edge_knobs" => "roll_profile_knobs",
                 "style.container.section.depth" => "section_depth",
                 "window_manager.bevel_shader" => "bevel_shader",
                 "window_manager.control_relief" => "control_relief",