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

commit450b00627fea9bf4ed0b86227ee8ef9c800061c8
parentbaf35a5701
authorLucas Galante <[email protected]>
date2026-08-27 15:05
feat: fill-less roll overlay — negative Plate depth (MODE_ROLL)

A window whose face is not a plate fill (the designer's full-bleed 3D
canvas) could not wear the window-edge roll: a Plate/Bevel owns fill and
roll together, and the Boss rim it used instead straddles the boundary —
the compositor clip eats the outer half, so the visible band ran half a
roll wide and started at mid-slope.

Negative depth on Prim::Plate is the fill-less sentinel: no fill, and
the roll — same profile, crest and specular as MODE_PLATE, full width
inside the silhouette — composites as an overlay (white screen / black
multiply) like the free carves. The batch is not opened as a carve host:
an overlay owns no surface for a CSG feature to cut into. The legacy
banded path draws nothing for it (no overlay compositing there). No
existing caller passes negative depth, so every current plate is
byte-identical.

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

 src/backend/window_runner.rs | 30 +++++++++++++++++++++++++-----
 src/scene/paint.rs           | 10 +++++++++-
 src/vk/shader2d.wgsl         | 28 ++++++++++++++++++++++++++++
 3 files changed, 62 insertions(+), 6 deletions(-)

diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index de9847c..48e648f 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -1706,11 +1706,24 @@ pub fn tessellate_display_list(
                 made_plate = Some(*rect);
             }
             Prim::Plate { rect, radii, color, depth } if shader_plates => {
-                // Same lit-plate branch; the cover quad is the exact rect so the
-                // silhouette and the compositor's rounded window corners agree.
-                verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *color));
-                plate = Some(plate_push_raised(rect, *radii, *depth, scale, plate_light, plate_mat, true));
-                made_plate = Some(*rect);
+                if *depth < 0.0 {
+                    // Negative depth = fill-less roll overlay (MODE_ROLL): the
+                    // window-edge roll shading alone, screened over whatever is
+                    // beneath — for a root plate whose face is not a fill (the
+                    // designer's 3D canvas). The cover quad carries no color,
+                    // and the batch is NOT opened as a carve host: an overlay
+                    // owns no surface for a CSG feature to cut into.
+                    verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, [0.0; 4]));
+                    let mut p = plate_push_raised(rect, *radii, -*depth, scale, plate_light, plate_mat, true);
+                    p.mode = 11.0; // MODE_ROLL
+                    plate = Some(p);
+                } else {
+                    // Same lit-plate branch; the cover quad is the exact rect so the
+                    // silhouette and the compositor's rounded window corners agree.
+                    verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *color));
+                    plate = Some(plate_push_raised(rect, *radii, *depth, scale, plate_light, plate_mat, true));
+                    made_plate = Some(*rect);
+                }
             }
             Prim::Recess { rect, radii, depth, edges, .. }
             | Prim::Boss { rect, radii, depth, edges, .. }
@@ -1945,6 +1958,13 @@ pub fn tessellate_display_list(
                 push_plate_bevel_vertices(rect.x, rect.y, rect.width, rect.height, radii.0, *depth, sw, sh, *color, no, &mut verts);
             }
             Prim::Plate { rect, radii, color, depth } => {
+                if *depth < 0.0 {
+                    // Fill-less roll overlay (negative-depth sentinel): the banded
+                    // legacy tessellation has no overlay compositing, so the roll
+                    // is simply absent here — the A/B path draws nothing rather
+                    // than a wrong fill.
+                    continue;
+                }
                 // Fill at full size (no inset — see Prim::Plate), then light the face,
                 // then roll the perimeter. The lip rides on top of the fill's outer band
                 // rather than replacing it, so the plate's silhouette and the
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index 4c2ad01..09e522f 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -52,7 +52,8 @@ pub struct PlateSpec {
     pub blur: bool,
     /// Which corners lie ON the window silhouette (TL, TR, BR, BL).
     pub window_corners: (bool, bool, bool, bool),
-    /// Transition-band width of the rolled perimeter.
+    /// Transition-band width of the rolled perimeter. Negative = the fill-less
+    /// roll-overlay sentinel (see [`PaintCtx::plate`]).
     pub depth: f32,
 }
 
@@ -1022,6 +1023,13 @@ impl PaintCtx {
     /// The window's glass slab: rounded fill at full size plus a rolled, lit perimeter.
     /// `depth` is the roll-off width in px — pass [`crate::layout::bevel_width`] unless the
     /// window wants a shallower edge than the DE default.
+    ///
+    /// A NEGATIVE `depth` is the fill-less sentinel: no fill is drawn, and the
+    /// rolled perimeter (width `-depth`) renders as an overlay — translucent
+    /// white screen / black multiply — over whatever is beneath, for a root
+    /// plate whose face is not a fill (the designer's full-bleed 3D canvas).
+    /// `color` is ignored; the roll profile, crest and specular are exactly the
+    /// positive-depth plate's.
     pub fn plate(&mut self, rect: Rect, radii: Radii, color: [f32; 4], depth: f32) {
         let rect = self.apply_offset(rect);
         self.push(Prim::Plate { rect, radii, color, depth });
diff --git a/src/vk/shader2d.wgsl b/src/vk/shader2d.wgsl
index 92efaf0..2bac4df 100644
--- a/src/vk/shader2d.wgsl
+++ b/src/vk/shader2d.wgsl
@@ -130,6 +130,7 @@ const MODE_FILLET_UP: i32 = 7;    // concave inside-corner wall, raised
 const MODE_GROOVE: i32 = 8;       // slab carve about an arbitrary line
 const MODE_TROUGH: i32 = 9;       // sunken valley straddling the boundary
 const MODE_DROPLET: i32 = 10;     // hanging water droplet clinging to the box top
+const MODE_ROLL: i32 = 11;        // fill-less rolled perimeter, composited as an overlay
 // Fillet modes rejoin the shared free-carve path as their flat equivalents.
 const FILLET_TO_STEP: i32 = 4;    // 6 -> RECESS, 7 -> BOSS
 
@@ -555,6 +556,33 @@ fn plate_shade(frag: vec2f, vcol: vec4f) -> vec4f {
         return vec4f(base.rgb * shade + rrect_clip.p_spec_tint.rgb * (spec * strength), abs(base.a) * aa);
     }
 
+    if (mode == MODE_ROLL) {
+        // Fill-less rolled perimeter: MODE_PLATE's roll — same profile, crest
+        // and specular, spanning the full width INSIDE the silhouette — for a
+        // window whose face is not a plate fill (the designer's full-bleed 3D
+        // canvas). With no fill to shade into, it composites like the free
+        // carves: darkening is a black multiply, brightening a translucent
+        // white screen, over whatever is beneath. No CSG features: an overlay
+        // owns no surface, so carves never group into it (the tessellator
+        // never opens it as a host).
+        let aa = clamp(d + 0.5, 0.0, 1.0);
+        if (aa <= 0.0) {
+            discard;
+        }
+        let u = clamp(d / t, 0.0, 1.0);
+        let f = 1.0 - u;
+        let sv = gd.xy * roll_slope(f);
+        let extra = PLATE_CREST * f * f * f;
+        let n = normalize(vec3f(sv, 1.0));
+        let diff = PLATE_AMBIENT + (1.0 - PLATE_AMBIENT) * max(dot(n, l), 0.0);
+        let spec = roll_spec(sv);
+        let v = (diff / flat_shade - 1.0 + extra + spec) * strength * aa;
+        if (v >= 0.0) {
+            return vec4f(1.0, 1.0, 1.0, min(v, 1.0));
+        }
+        return vec4f(0.0, 0.0, 0.0, min(-v, 1.0));
+    }
+
     // Free-floating recess, boss, or ridge (one not grouped into a host plate —
     // e.g. in a widget's own paint): an overlay over whatever is painted
     // beneath — no fill, no silhouette. Junction behavior here is the heuristic