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

commitf105d5c0901be08d41bad682b6ce0334b8398c5a
parent6062207bc7
authorLucas Galante <[email protected]>
date2026-08-24 14:28
droplet: attach-radius taper and belly-off — the oval dewdrop

The sheet's top corners get their own radius (DropletSpec::attach,
p_host.w): the meniscus taper that curves the silhouette into the
attach line instead of meeting it square. belly <= 0 now disables the
belly capsule outright (the shader skips the smin). Defaults move to
the oval dewdrop — no belly, no sag, attach 0.35 + sheet_r 0.55 — one
continuous curve from tapered contact to round bottom; the old
clinging-pool look stays reachable (belly > 0, sag > 0). When
attach + sheet_r overfill the sheet height the pair scales down
proportionally, so 0.5 + 0.5 is the exact egg. Legacy fallback keeps
silhouette parity via the same two radii.

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

 src/backend/window_runner.rs | 40 +++++++++++++++++++++++++++++-----------
 src/scene/paint.rs           | 33 +++++++++++++++++++++++----------
 src/vk/shader2d.wgsl         | 31 ++++++++++++++++++++-----------
 3 files changed, 72 insertions(+), 32 deletions(-)

diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 39f51e5..2e1a160 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -1985,11 +1985,27 @@ pub fn tessellate_display_list(
                 let hx = rect.width * 0.5;
                 let hy = rect.height * 0.5;
                 let sag = spec.sag.clamp(0.0, 0.9) * rect.height;
-                let br = (spec.belly.clamp(0.05, 1.0) * rect.height).min(hy).min(hx);
-                let bw = ((hx - br).max(0.0) * spec.belly_w.clamp(0.0, 1.0)).max(1.0);
+                // belly ≤ 0 disables the belly outright (the oval-dewdrop
+                // default) — the shader skips the smin when the radius is 0.
+                let (br, bw) = if spec.belly > 0.0 {
+                    let br = (spec.belly.min(1.0) * rect.height).min(hy).min(hx);
+                    (br, ((hx - br).max(0.0) * spec.belly_w.clamp(0.0, 1.0)).max(1.0))
+                } else {
+                    (0.0, 0.0)
+                };
                 let k = (spec.blend.max(0.0) * rect.height).max(1.0);
                 let sheet_hy = hy - sag * 0.5;
-                let sr = (spec.sheet_r.clamp(0.0, 1.0) * rect.height).min(sheet_hy.max(0.0)).min(hx);
+                // Bottom (sheet_r) and top (attach) corner radii: when the
+                // pair overfills the sheet height, scale both down
+                // proportionally — 0.5 + 0.5 is the fully continuous egg.
+                let mut sr = (spec.sheet_r.clamp(0.0, 1.0) * rect.height).min(hx);
+                let mut ar = (spec.attach.clamp(0.0, 1.0) * rect.height).min(hx);
+                let sheet_h = (2.0 * sheet_hy).max(0.0);
+                if sr + ar > sheet_h && sr + ar > 0.0 {
+                    let f = sheet_h / (sr + ar);
+                    sr *= f;
+                    ar *= f;
+                }
                 let band = (spec.band.max(0.05) * rect.height).max(1.0);
                 plate = Some(crate::vk::PlatePush {
                     rect: [
@@ -2004,19 +2020,21 @@ pub fn tessellate_display_list(
                     // droplets carry their own gleam/shine/rim instead of the
                     // DE material's (a drop is wetter than the DE's plates).
                     material: [plate_mat[0], spec.gleam, spec.shine, spec.rim],
-                    host: [sr * scale, spec.clarity.clamp(0.0, 1.0), spec.dome, 0.0],
+                    host: [sr * scale, spec.clarity.clamp(0.0, 1.0), spec.dome, ar * scale],
                     specular_tint: [1.0, 1.0, 1.0, 0.0],
                     mode: 10.0,
                     shape: 2.0,
                 });
             }
-            Prim::Droplet { rect, color, .. } => {
-                // Legacy banded path: the flat hanging capsule — square top,
-                // round bottom. Degrades the material but keeps the silhouette
-                // (a prim with no arm here VANISHES, it doesn't degrade — see
-                // Ridge/Groove above).
-                let r = (rect.height * 0.5).min(rect.width * 0.5);
-                let radii = crate::widget::CornerRadii::new(0.0, 0.0, r, r);
+            Prim::Droplet { rect, color, spec } => {
+                // Legacy banded path: the flat drop outline — attach-tapered
+                // top, round bottom. Degrades the material but keeps the
+                // silhouette (a prim with no arm here VANISHES, it doesn't
+                // degrade — see Ridge/Groove above).
+                let cap = (rect.height * 0.5).min(rect.width * 0.5);
+                let sr = (spec.sheet_r.clamp(0.0, 1.0) * rect.height).min(cap);
+                let ar = (spec.attach.clamp(0.0, 1.0) * rect.height).min(cap);
+                let radii = crate::widget::CornerRadii::new(ar, ar, sr, sr);
                 push_rounded_rect_vertices_corners(rect.x, rect.y, rect.width, rect.height, radii, sw, sh, *color, no, None, &mut verts);
             }
             Prim::ConcaveFillet { cx, cy, radius, depth, start: a0, raised } if shader_plates => {
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index eec3990..ebf3a75 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -66,7 +66,9 @@ pub struct DropletSpec {
     /// How far the sheet's bottom lifts above the rect bottom (the waist the
     /// sides pull up into), fraction of height. 0 = no waist (a capsule).
     pub sag: f32,
-    /// Belly capsule radius, fraction of height.
+    /// Belly capsule radius, fraction of height. **≤ 0 disables the belly**:
+    /// the drop is the sheet alone — with `attach` and `sheet_r` rounding its
+    /// top and bottom this is the oval dewdrop, and the default.
     pub belly: f32,
     /// Belly half-width, fraction of the half-width left after the belly
     /// radius (1 = the belly spans the whole bottom).
@@ -76,6 +78,13 @@ pub struct DropletSpec {
     pub blend: f32,
     /// Sheet bottom-corner radius, fraction of height.
     pub sheet_r: f32,
+    /// Sheet TOP-corner radius (the meniscus taper at the attach line),
+    /// fraction of height. 0 = the sides meet the attach edge square (the
+    /// clinging-pool look); larger values narrow the contact span so the
+    /// silhouette curves into the edge like a dewdrop. When `attach + sheet_r`
+    /// exceeds the sheet height the pair scales down proportionally, so 0.5 +
+    /// 0.5 is the fully continuous egg curve with no straight side segment.
+    pub attach: f32,
     /// Tint opacity at the deep interior relative to the color's own alpha;
     /// the rim falls toward `clarity` × that (thin water is clearer). 1 = flat.
     pub clarity: f32,
@@ -93,18 +102,22 @@ pub struct DropletSpec {
 
 impl Default for DropletSpec {
     fn default() -> Self {
+        // The oval dewdrop: no belly, no sag — one continuous curve from a
+        // tapered attach line to a fully round bottom. The pendant-pool look
+        // is reachable by setting `belly` > 0 (and usually some `sag`).
         Self {
-            sag: 0.45,
-            belly: 0.75,
-            belly_w: 0.85,
+            sag: 0.0,
+            belly: 0.0,
+            belly_w: 0.5,
             blend: 0.35,
-            sheet_r: 0.3,
-            clarity: 0.55,
-            dome: 1.0,
+            sheet_r: 0.55,
+            attach: 0.35,
+            clarity: 0.5,
+            dome: 0.9,
             band: 0.9,
-            gleam: 1.2,
-            shine: 24.0,
-            rim: 0.35,
+            gleam: 1.4,
+            shine: 32.0,
+            rim: 0.5,
         }
     }
 }
diff --git a/src/vk/shader2d.wgsl b/src/vk/shader2d.wgsl
index 1027637..2214a13 100644
--- a/src/vk/shader2d.wgsl
+++ b/src/vk/shader2d.wgsl
@@ -352,7 +352,8 @@ fn plate_shade(frag: vec2f, vcol: vec4f) -> vec4f {
     // Field reinterpretation (the push block cannot grow):
     //   p_rect  = the droplet box, center + half-extents (like a plate);
     //   p_radii = [sag, belly radius, belly half-width, blend k] px;
-    //   p_host  = [sheet corner radius px, edge clarity 0-1, dome amplitude, _];
+    //   p_host  = [sheet bottom-corner radius px, edge clarity 0-1, dome
+    //              amplitude, attach (top-corner) radius px];
     //   p_mat.w = fresnel rim crest amplitude (droplets carve nothing, so the
     //             AO slot is free); p_light.w = shaded band width px.
     // The silhouette is the smooth union of a film SHEET attached to the top
@@ -375,18 +376,26 @@ fn plate_shade(frag: vec2f, vcol: vec4f) -> vec4f {
         let sr = rrect_clip.p_host.x;
         let clarity = rrect_clip.p_host.y;
         let dome = rrect_clip.p_host.z;
+        let ar = rrect_clip.p_host.w;
 
-        // Sheet: bottom lifted by sag, top corners square (the attach line).
+        // Sheet: bottom lifted by sag; top corners carry the attach radius —
+        // the meniscus taper that curves the sides into the attach line (0 =
+        // the square-shouldered clinging-pool look).
         let a_rect = vec4f(c.x, c.y - sag * 0.5, hx, hy - sag * 0.5);
-        let ga = rr_sdf_grad(frag, a_rect, vec4f(0.0, 0.0, sr, sr));
-        // Belly: a horizontal capsule resting on the box bottom.
-        let b_rect = vec4f(c.x, c.y + hy - br, bw, br);
-        let gb = rr_sdf_grad(frag, b_rect, vec4f(br));
-        // Polynomial smooth union — one drop, smooth neck. The gradient is the
-        // same weighted mix as the distance, renormalized.
-        let hm = clamp(0.5 + 0.5 * (gb.z - ga.z) / k, 0.0, 1.0);
-        let d = mix(gb.z, ga.z, hm) - k * hm * (1.0 - hm);
-        let g = normalize(mix(gb.xy, ga.xy, hm));
+        let ga = rr_sdf_grad(frag, a_rect, vec4f(ar, ar, sr, sr));
+        var d = ga.z;
+        var g = ga.xy;
+        // Belly (radius > 0 only): a horizontal capsule resting on the box
+        // bottom, joined by polynomial smooth union — one drop, smooth neck.
+        // The gradient is the same weighted mix as the distance, renormalized.
+        if (br > 0.5) {
+            let b_rect = vec4f(c.x, c.y + hy - br, bw, br);
+            let gb = rr_sdf_grad(frag, b_rect, vec4f(br));
+            let hm = clamp(0.5 + 0.5 * (gb.z - ga.z) / k, 0.0, 1.0);
+            d = mix(gb.z, ga.z, hm) - k * hm * (1.0 - hm);
+            g = mix(gb.xy, ga.xy, hm);
+        }
+        g = normalize(g);
 
         let din = -d;
         let aa2 = clamp(din + 0.5, 0.0, 1.0);