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

commitb16c40bfda68fa6e1fc6bd9d76a2cb15185258fc
parent3434957363
authorLucas Galante <[email protected]>
date2026-07-16 20:58
feat: rounded-rect clipping so plate children cut off at rounded corners

New PaintCtx::clip_rounded (push_clip_rounded / pop_clip_rounded): pushes
the rect as a scissor and records a rounded-rect SDF clip
[cx, cy, bx, by, r] on emitted items. Geometry applies it per draw batch
via push constants on the 2D pipeline (fragments outside the SDF
discard); DlBatch/Batch2D carry the clip and batches split on it. Text
applies it per glyph: TextSpan/GlyphVertex gain clip_extents, and
glyph.wgsl's circle test generalizes to the rounded-rect SDF (zero
extents = the old circle, so the circular pane is unchanged). TextItem
carries the paint item's clip_rrect into the glyph pass.

naga validation now enables PUSH_CONSTANT; the 2D pipeline layout
declares a 32-byte fragment push-constant range, cleared before the
overlay draw.

TextSpan gains a required clip_extents field — consumers constructing
spans directly add clip_extents: [0.0; 2].

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

 src/backend/window_runner.rs    | 34 ++++++++++++++++++--------
 src/scene/paint.rs              | 54 ++++++++++++++++++++++++++++++++++++++---
 src/vk/glyph.wgsl               | 15 ++++++++----
 src/vk/renderer.rs              | 40 +++++++++++++++++++++++++++---
 src/vk/shader2d.wgsl            | 16 ++++++++++++
 src/vk/text.rs                  | 20 ++++++++++++---
 src/widget/display/label.rs     |  1 +
 src/widget/display/list_item.rs |  5 +++-
 8 files changed, 157 insertions(+), 28 deletions(-)

diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 44efdc4..85e8b76 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -1003,10 +1003,13 @@ pub fn push_widget_vertices(w: &dyn crate::widget::WidgetHost, sw: f32, sh: f32,
     }
 }
 
-/// A contiguous run of vertices sharing one scissor rect (Phase 3 single paint path). `scissor` is
-/// a logical-pixel clip (`None` = unclipped); `start..end` indexes the flat vertex buffer.
+/// A contiguous run of vertices sharing one scissor rect (Phase 3 single paint path) and one
+/// rounded-rect clip. `scissor` is a logical-pixel clip (`None` = unclipped); `clip_rrect` is
+/// the paint walk's `[cx, cy, bx, by, r]` rounded clip in logical px (`None` = unclipped),
+/// applied as per-draw push-constant state; `start..end` indexes the flat vertex buffer.
 pub struct DlBatch {
     pub scissor: Option<crate::scene::layout::Rect>,
+    pub clip_rrect: Option<[f32; 5]>,
     pub start: u32,
     pub end: u32,
 }
@@ -1116,14 +1119,14 @@ pub fn tessellate_display_list(
                 v.clip_circle = no;
             }
         }
-        // Merge into the previous batch if it shares this clip and is contiguous.
+        // Merge into the previous batch if it shares this clip pair and is contiguous.
         if let Some(last) = batches.last_mut() {
-            if last.scissor == item.clip && last.end == start {
+            if last.scissor == item.clip && last.clip_rrect == item.clip_rrect && last.end == start {
                 last.end = end;
                 continue;
             }
         }
-        batches.push(DlBatch { scissor: item.clip, start, end });
+        batches.push(DlBatch { scissor: item.clip, clip_rrect: item.clip_rrect, start, end });
     }
 
     (verts, batches, images)
@@ -1893,6 +1896,7 @@ impl<A: Application> EngineState<A> {
                         ),
                         bounds: merged,
                         clip_circle: item.clip_circle,
+                        clip_rrect: item.clip_rrect,
                     });
                 }
             }
@@ -1903,7 +1907,7 @@ impl<A: Application> EngineState<A> {
         let pre_custom = verts.len() as u32;
         self.inner.as_mut().unwrap().custom_vertices(&mut verts, LogicalSize::new(logical_w, logical_h), scale_factor);
         if (verts.len() as u32) > pre_custom {
-            dl_batches.push(DlBatch { scissor: None, start: pre_custom, end: verts.len() as u32 });
+            dl_batches.push(DlBatch { scissor: None, clip_rrect: None, start: pre_custom, end: verts.len() as u32 });
         }
 
         // 1b. Overlay quads (drawn after the text pass).
@@ -1979,10 +1983,17 @@ impl<A: Application> EngineState<A> {
                     ti.color.a() as f32 / 255.0,
                 ],
                 rotation: None,
-                clip_circle: ti
-                    .clip_circle
-                    .map(|c| [c[0] * scale_f32, c[1] * scale_f32, c[2] * scale_f32])
-                    .unwrap_or([0.0; 3]),
+                // Circle wins when both are set (the circular pane's innermost clip);
+                // otherwise a rounded-rect clip rides as center+radius with extents.
+                clip_circle: match (ti.clip_circle, ti.clip_rrect) {
+                    (Some(c), _) => [c[0] * scale_f32, c[1] * scale_f32, c[2] * scale_f32],
+                    (None, Some(rr)) => [rr[0] * scale_f32, rr[1] * scale_f32, rr[4] * scale_f32],
+                    (None, None) => [0.0; 3],
+                },
+                clip_extents: match (ti.clip_circle, ti.clip_rrect) {
+                    (None, Some(rr)) => [rr[2] * scale_f32, rr[3] * scale_f32],
+                    _ => [0.0; 2],
+                },
             });
         }
 
@@ -2029,6 +2040,9 @@ impl<A: Application> EngineState<A> {
                         (clip.height * scale_f32) as u32,
                     )
                 }),
+                clip_rrect: batch
+                    .clip_rrect
+                    .map(|c| [c[0] * scale_f32, c[1] * scale_f32, c[2] * scale_f32, c[3] * scale_f32, c[4] * scale_f32]),
                 start: batch.start,
                 end: batch.end,
             })
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index 0fadce3..43b3cc3 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -104,16 +104,19 @@ pub struct TextAttrs {
     pub weight: Option<u16>,
 }
 
-/// A primitive plus the scissor rect it must be clipped to (`None` = unclipped), and an
+/// A primitive plus the scissor rect it must be clipped to (`None` = unclipped), an
 /// optional circular clip `[cx, cy, r]` in logical pixels (`None` = unclipped) — the
 /// per-vertex circle clip the tessellators already support, for round panes (the designer's
-/// circular network pane). Both clips compose: the scissor is GPU state, the circle rides
-/// the vertices.
+/// circular network pane) — and an optional rounded-rect clip `[cx, cy, bx, by, r]`
+/// (center, SDF half-extents = half-size minus radius, corner radius; logical px) so a
+/// plate's children cut off at its rounded corners. The clips compose: the scissor is GPU
+/// state, the circle rides the vertices, the rounded rect is per-draw-batch state.
 #[derive(Clone, Debug, PartialEq)]
 pub struct PaintItem {
     pub prim: Prim,
     pub clip: Option<Rect>,
     pub clip_circle: Option<[f32; 3]>,
+    pub clip_rrect: Option<[f32; 5]>,
 }
 
 /// An ordered list of clipped primitives — the single source of truth for a frame's geometry.
@@ -155,6 +158,8 @@ pub struct PaintCtx {
     /// Active circular clips; primitives record the innermost (`last`). Circles don't
     /// intersect analytically like rects, so nesting keeps the innermost only.
     clip_circle_stack: Vec<[f32; 3]>,
+    /// Active rounded-rect clips `[cx, cy, bx, by, r]`; innermost wins, like circles.
+    clip_rrect_stack: Vec<[f32; 5]>,
     /// Saved offsets for nesting; `offset` is the current cumulative translation.
     offset_stack: Vec<(f32, f32)>,
     offset: (f32, f32),
@@ -172,6 +177,7 @@ impl PaintCtx {
             list: DisplayList::new(),
             clip_stack: Vec::new(),
             clip_circle_stack: Vec::new(),
+            clip_rrect_stack: Vec::new(),
             offset_stack: Vec::new(),
             offset: (0.0, 0.0),
         }
@@ -225,6 +231,43 @@ impl PaintCtx {
         out
     }
 
+    /// Push a rounded-rect clip: `rect` (current local space) with corner radius `radius`,
+    /// so children of a rounded plate cut off at its corners. Pushes the rect as a scissor
+    /// too — the scissor handles the straight edges (and keeps batching), the SDF trims the
+    /// corners. A radius of zero degenerates to the plain rect clip. Pair with
+    /// [`pop_clip_rounded`](PaintCtx::pop_clip_rounded), or prefer
+    /// [`clip_rounded`](PaintCtx::clip_rounded).
+    pub fn push_clip_rounded(&mut self, rect: Rect, radius: f32) {
+        self.push_clip(rect);
+        let r = radius.max(0.0);
+        if r > 0.0 {
+            let abs = self.apply_offset(rect);
+            self.clip_rrect_stack.push([
+                abs.x + abs.width / 2.0,
+                abs.y + abs.height / 2.0,
+                (abs.width / 2.0 - r).max(0.0),
+                (abs.height / 2.0 - r).max(0.0),
+                r,
+            ]);
+        } else {
+            // Keep push/pop balanced regardless of radius.
+            self.clip_rrect_stack.push([0.0; 5]);
+        }
+    }
+
+    pub fn pop_clip_rounded(&mut self) {
+        self.clip_rrect_stack.pop();
+        self.pop_clip();
+    }
+
+    /// Run `f` with `rect` (radius `radius`) pushed as a rounded clip, popping it afterward.
+    pub fn clip_rounded<R>(&mut self, rect: Rect, radius: f32, f: impl FnOnce(&mut Self) -> R) -> R {
+        self.push_clip_rounded(rect, radius);
+        let out = f(self);
+        self.pop_clip_rounded();
+        out
+    }
+
     /// Run `f` with an additional translation applied to all emitted coordinates.
     pub fn translate<R>(&mut self, dx: f32, dy: f32, f: impl FnOnce(&mut Self) -> R) -> R {
         self.offset_stack.push(self.offset);
@@ -241,7 +284,10 @@ impl PaintCtx {
     fn push(&mut self, prim: Prim) {
         let clip = self.current_clip();
         let clip_circle = self.clip_circle_stack.last().copied();
-        self.list.items.push(PaintItem { prim, clip, clip_circle });
+        // r == 0 entries are balance placeholders (a zero-radius rounded clip is just its
+        // scissor rect) — record no rounded clip so batches keep merging.
+        let clip_rrect = self.clip_rrect_stack.last().copied().filter(|c| c[4] > 0.0);
+        self.list.items.push(PaintItem { prim, clip, clip_circle, clip_rrect });
     }
 
     pub fn quad(&mut self, rect: Rect, color: [f32; 4]) {
diff --git a/src/vk/glyph.wgsl b/src/vk/glyph.wgsl
index 8cd26e0..e85478f 100644
--- a/src/vk/glyph.wgsl
+++ b/src/vk/glyph.wgsl
@@ -10,6 +10,7 @@ struct VertexOutput {
     @location(0) uv: vec2f,
     @location(1) color: vec4f,
     @location(2) clip_circle: vec3f,
+    @location(3) clip_extents: vec2f,
 }
 
 @vertex
@@ -18,23 +19,27 @@ fn vs_main(
     @location(1) uv: vec2f,
     @location(2) color: vec4f,
     @location(3) clip_circle: vec3f,
+    @location(4) clip_extents: vec2f,
 ) -> VertexOutput {
     var out: VertexOutput;
     out.clip_position = vec4f(position, 0.0, 1.0);
     out.uv = uv;
     out.color = color;
     out.clip_circle = clip_circle;
+    out.clip_extents = clip_extents;
     return out;
 }
 
 @fragment
 fn fs_main(in: VertexOutput) -> @location(0) vec4f {
-    // Same circle clip as shader.wgsl (framebuffer px): used by the circular
-    // network pane's curved rim labels.
+    // Rounded-rect SDF clip in framebuffer px: center clip_circle.xy, corner radius
+    // clip_circle.z, inner-box half-size clip_extents. Zero extents degenerate to the
+    // plain circle clip (the circular network pane's curved rim labels); a non-zero
+    // box clips plate children at the plate's rounded corners.
     if (in.clip_circle.z > 0.0) {
-        let dx = in.clip_position.x - in.clip_circle.x;
-        let dy = in.clip_position.y - in.clip_circle.y;
-        if (dx * dx + dy * dy > in.clip_circle.z * in.clip_circle.z) {
+        let q = abs(in.clip_position.xy - in.clip_circle.xy) - in.clip_extents;
+        let d = length(max(q, vec2f(0.0))) - in.clip_circle.z;
+        if (d > 0.0) {
             discard;
         }
     }
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index 216a166..c4f2738 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -21,9 +21,13 @@ use super::scene::{MeshId, SceneDraw, SceneStage, Vertex3D};
 use super::text::{TextSpan, TextStage};
 
 /// One scissored draw range of a 2D frame. `scissor` is (x, y, w, h) in
-/// physical pixels; None draws with the full-surface scissor.
+/// physical pixels; None draws with the full-surface scissor. `clip_rrect` is an
+/// optional rounded-rect clip `[cx, cy, bx, by, r]` (center, SDF half-extents, corner
+/// radius; physical px) applied via push constants — fragments outside it discard, so a
+/// plate's children cut off at its rounded corners.
 pub struct Batch2D {
     pub scissor: Option<(u32, u32, u32, u32)>,
+    pub clip_rrect: Option<[f32; 5]>,
     pub start: u32,
     pub end: u32,
 }
@@ -175,7 +179,7 @@ pub(crate) fn compile_wgsl(source: &str) -> Vec<u32> {
     let module = naga::front::wgsl::parse_str(source).expect("WGSL parse failed");
     let info = naga::valid::Validator::new(
         naga::valid::ValidationFlags::all(),
-        naga::valid::Capabilities::empty(),
+        naga::valid::Capabilities::PUSH_CONSTANT,
     )
     .validate(&module)
     .expect("WGSL validation failed");
@@ -450,9 +454,17 @@ impl VkRenderer {
             .expect("Failed to create descriptor set layout");
 
         let set_layouts = [descriptor_set_layout];
+        // Push constants: the per-batch rounded-rect clip (two vec4s — [cx, cy, bx, by]
+        // and [r, enabled, 0, 0]) read by shader2d's fragment stage.
+        let push_ranges = [vk::PushConstantRange::default()
+            .stage_flags(vk::ShaderStageFlags::FRAGMENT)
+            .offset(0)
+            .size(32)];
         let pipeline_layout = device
             .create_pipeline_layout(
-                &vk::PipelineLayoutCreateInfo::default().set_layouts(&set_layouts),
+                &vk::PipelineLayoutCreateInfo::default()
+                    .set_layouts(&set_layouts)
+                    .push_constant_ranges(&push_ranges),
                 None,
             )
             .expect("Failed to create pipeline layout");
@@ -1287,7 +1299,7 @@ impl VkRenderer {
                 let mut img_i = 0usize;
 
                 let default_batch =
-                    [Batch2D { scissor: None, start: 0, end: frame.vertex_count }];
+                    [Batch2D { scissor: None, clip_rrect: None, start: 0, end: frame.vertex_count }];
                 let batches: &[Batch2D] =
                     if frame2d.batches.is_empty() { &default_batch } else { frame2d.batches };
 
@@ -1351,6 +1363,17 @@ impl VkRenderer {
                             self.core.device
                                 .cmd_bind_vertex_buffers(cmd, 0, &[frame.vertex.buffer], &[0]);
                             self.core.device.cmd_set_scissor(cmd, 0, &[scissor]);
+                            // Per-batch rounded-rect clip (fragments outside discard).
+                            let rr = batch.clip_rrect.unwrap_or([0.0; 5]);
+                            let enabled = if batch.clip_rrect.is_some() { 1.0f32 } else { 0.0 };
+                            let pc = [rr[0], rr[1], rr[2], rr[3], rr[4], enabled, 0.0, 0.0];
+                            self.core.device.cmd_push_constants(
+                                cmd,
+                                self.pipeline_layout,
+                                vk::ShaderStageFlags::FRAGMENT,
+                                0,
+                                bytemuck::cast_slice(&pc),
+                            );
                             self.core.device.cmd_draw(cmd, upto - cursor, 1, cursor, 0);
                         }
                         cursor = upto;
@@ -1391,6 +1414,15 @@ impl VkRenderer {
                 );
                 self.core.device
                     .cmd_bind_vertex_buffers(cmd, 0, &[frame.vertex.buffer], &[0]);
+                // Push constants persist across binds — clear any batch's rounded clip.
+                let pc = [0.0f32; 8];
+                self.core.device.cmd_push_constants(
+                    cmd,
+                    self.pipeline_layout,
+                    vk::ShaderStageFlags::FRAGMENT,
+                    0,
+                    bytemuck::cast_slice(&pc),
+                );
                 self.core.device
                     .cmd_draw(cmd, frame.overlay_count, 1, frame.overlay_start, 0);
             }
diff --git a/src/vk/shader2d.wgsl b/src/vk/shader2d.wgsl
index edf97ef..5cd6199 100644
--- a/src/vk/shader2d.wgsl
+++ b/src/vk/shader2d.wgsl
@@ -55,6 +55,14 @@ fn is_outside_window_corners(pos: vec2<f32>) -> bool {
     return false;
 }
 
+// Per-batch rounded-rect clip: rect0 = [cx, cy, bx, by] (center + SDF half-extents),
+// rect1 = [corner radius, enabled flag, 0, 0]. Physical pixels, like clip_position.
+struct RRectClip {
+    rect0: vec4f,
+    rect1: vec4f,
+}
+var<push_constant> rrect_clip: RRectClip;
+
 struct VertexOutput {
     @builtin(position) clip_position: vec4f,
     @location(0) color: vec4f,
@@ -119,6 +127,14 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4f {
             discard;
         }
     }
+    // Rounded-rect clip (per-batch): SDF of the round-cornered box; outside discards.
+    if (rrect_clip.rect1.y > 0.5) {
+        let q = abs(in.clip_position.xy - rrect_clip.rect0.xy) - rrect_clip.rect0.zw;
+        let d = length(max(q, vec2f(0.0))) - rrect_clip.rect1.x;
+        if (d > 0.0) {
+            discard;
+        }
+    }
 
     // Blur-behind plate: negative alpha mixes the (blurred) backdrop with the
     // plate color at |alpha| opacity.
diff --git a/src/vk/text.rs b/src/vk/text.rs
index 8403e24..9dbec40 100644
--- a/src/vk/text.rs
+++ b/src/vk/text.rs
@@ -48,6 +48,11 @@ pub struct TextSpan<'a> {
     /// Fragment circle clip (center_x, center_y, radius) in physical pixels;
     /// zero radius disables (matches shader.wgsl's clip_circle).
     pub clip_circle: [f32; 3],
+    /// Rounded-rect clip half-extents (physical px). Zero keeps `clip_circle` a plain
+    /// circle; non-zero reinterprets it as a rounded-rect SDF clip — center
+    /// `clip_circle.xy`, corner radius `clip_circle.z`, inner box half-size
+    /// `clip_extents` — so plate children (labels included) cut off at rounded corners.
+    pub clip_extents: [f32; 2],
 }
 
 #[repr(C)]
@@ -57,6 +62,7 @@ struct GlyphVertex {
     uv: [f32; 2],
     color: [f32; 4],
     clip_circle: [f32; 3],
+    clip_extents: [f32; 2],
 }
 
 #[derive(Clone, Copy)]
@@ -210,6 +216,11 @@ impl TextStage {
                     .binding(0)
                     .format(vk::Format::R32G32B32_SFLOAT)
                     .offset(32),
+                vk::VertexInputAttributeDescription::default()
+                    .location(4)
+                    .binding(0)
+                    .format(vk::Format::R32G32_SFLOAT)
+                    .offset(44),
             ];
             let vertex_input = vk::PipelineVertexInputStateCreateInfo::default()
                 .vertex_binding_descriptions(&vertex_bindings)
@@ -586,10 +597,11 @@ impl TextStage {
                     };
                     let uv = |u: f32, v: f32| [u / ATLAS_SIZE as f32, v / ATLAS_SIZE as f32];
                     let clip_circle = span.clip_circle;
-                    let tl = GlyphVertex { position: ndc(corners[0]), uv: uv(u0, v0), color, clip_circle };
-                    let tr = GlyphVertex { position: ndc(corners[1]), uv: uv(u1, v0), color, clip_circle };
-                    let bl = GlyphVertex { position: ndc(corners[2]), uv: uv(u0, v1), color, clip_circle };
-                    let br = GlyphVertex { position: ndc(corners[3]), uv: uv(u1, v1), color, clip_circle };
+                    let clip_extents = span.clip_extents;
+                    let tl = GlyphVertex { position: ndc(corners[0]), uv: uv(u0, v0), color, clip_circle, clip_extents };
+                    let tr = GlyphVertex { position: ndc(corners[1]), uv: uv(u1, v0), color, clip_circle, clip_extents };
+                    let bl = GlyphVertex { position: ndc(corners[2]), uv: uv(u0, v1), color, clip_circle, clip_extents };
+                    let br = GlyphVertex { position: ndc(corners[3]), uv: uv(u1, v1), color, clip_circle, clip_extents };
                     self.pending_vertices.extend([tl, tr, bl, tr, br, bl]);
                 }
             }
diff --git a/src/widget/display/label.rs b/src/widget/display/label.rs
index 8443e27..50ee8ae 100644
--- a/src/widget/display/label.rs
+++ b/src/widget/display/label.rs
@@ -245,6 +245,7 @@ impl StyledLabel {
             color: self.g_color,
             bounds: None,
             clip_circle: None,
+            clip_rrect: None,
         });
         w
     }
diff --git a/src/widget/display/list_item.rs b/src/widget/display/list_item.rs
index 830a734..9f98b82 100644
--- a/src/widget/display/list_item.rs
+++ b/src/widget/display/list_item.rs
@@ -17,6 +17,9 @@ pub struct TextItem {
     /// Optional circular clip `[cx, cy, r]` in logical pixels (a display-list item's
     /// `clip_circle` carried through to the glyph pass). `None` for ordinary labels.
     pub clip_circle: Option<[f32; 3]>,
+    /// Optional rounded-rect clip `[cx, cy, bx, by, r]` in logical pixels (a display-list
+    /// item's `clip_rrect` carried through to the glyph pass). `None` for ordinary labels.
+    pub clip_rrect: Option<[f32; 5]>,
 }
 
 impl TextItem {
@@ -31,7 +34,7 @@ impl TextItem {
         bounds: Option<[f32; 4]>,
     ) -> Self {
         let buffer = crate::backend::window_runner::get_text_buffer(fs, text, size, font);
-        Self { buffer, x, y, color, bounds, clip_circle: None }
+        Self { buffer, x, y, color, bounds, clip_circle: None, clip_rrect: None }
     }
 }