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

commit94ebff4b66fe911bb18cb5a71b125ea2170d6e0b
parent1ef78749e7
authorLucas Galante <[email protected]>
date2026-09-20 20:07
Prim::Grout: flat colour outside a field of rounded cells; the graph grid uses it

Flat strips gave the graph square cells: no quad can paint the notch a
rounded cell leaves at each crossing. Grout is the lattice's nearest-cell
fold painted flat (shader mode 15) — the vertex colour wherever the cell
SDF is outside, 1px anti-aliased — so the whole grid is one draw with the
cells' superellipse corners exact, at the node corner radius the DE's
cells and cursors already share. Graph::paint_grid emits one grout plus
the two axis lines; the probe test pins the emission and the mode-15
batch in its colour.

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

 src/backend/window_runner.rs | 26 ++++++++++++++++++++++----
 src/scene/paint.rs           | 19 +++++++++++++++++++
 src/vk/shader2d.wgsl         | 22 ++++++++++++++++++++++
 src/widget/display/graph.rs  | 43 +++++++++++++++++--------------------------
 tests/lattice_probe.rs       | 20 ++++++++++++++++----
 5 files changed, 96 insertions(+), 34 deletions(-)

diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index c385faa..9ba27d2 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -1712,7 +1712,7 @@ fn prim_kind(p: &crate::scene::paint::Prim) -> &'static str {
         P::Sphere { .. } => "Sphere", P::Droplet { .. } => "Droplet",
         P::DropletScrim { .. } => "DropletScrim",
         P::ConcaveFillet { .. } => "ConcaveFillet",
-        P::Groove { .. } => "Groove", P::Lattice { .. } => "Lattice",
+        P::Groove { .. } => "Groove", P::Lattice { .. } => "Lattice", P::Grout { .. } => "Grout",
         P::CarveUnion { .. } => "CarveUnion", P::Glow { .. } => "Glow",
         P::Text { .. } => "Text", P::Image { .. } => "Image",
     }
@@ -2636,9 +2636,27 @@ pub fn tessellate_display_list(
                     shape: crate::layout::corner_shape(),
                 });
             }
-            // Legacy banded path: no periodic wall — the lattice draws nothing
-            // there, like the fillet (A/B comparison path only).
-            Prim::Lattice { .. } => {}
+            Prim::Grout { rect, period, origin, cell, radius, color } if shader_plates => {
+                // The lattice's fold, painted flat (shader mode 15): one cover
+                // quad in the grout colour; the shader keeps it outside the
+                // cells. Same push layout as the lattice; light/material are
+                // carried but unread.
+                let (pw, ph) = (period.0.max(1e-3), period.1.max(1e-3));
+                verts.extend(quad_vertices(rect.x, rect.y, rect.width, rect.height, sw, sh, *color));
+                plate = Some(crate::vk::PlatePush {
+                    rect: [origin.0 * scale, origin.1 * scale, cell.0 * 0.5 * scale, cell.1 * 0.5 * scale],
+                    radii: [*radius * scale; 4],
+                    light: [plate_light[0], plate_light[1], plate_light[2], 0.0],
+                    material: plate_mat,
+                    host: [pw * scale, ph * scale, 1e6, 1e6],
+                    specular_tint: [1.0, 1.0, 1.0, 0.0],
+                    mode: 15.0,
+                    shape: crate::layout::corner_shape(),
+                });
+            }
+            // Legacy banded path: no periodic wall — the lattice and the grout
+            // draw nothing there, like the fillet (A/B comparison path only).
+            Prim::Lattice { .. } | Prim::Grout { .. } => {}
             Prim::CarveUnion { boxes, depth, raised } if shader_plates => {
                 // The union of several boxes as ONE wall (shader mode 14): the
                 // boxes go into the frame's feature buffer as a contiguous run
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index 8fb8b3e..de7f7ab 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -639,6 +639,14 @@ pub enum Prim {
     /// surface holds (a free carve per cell also runs into the per-frame
     /// feature budget long before a zoomed-out grid does). SDF path only.
     Lattice { rect: Rect, period: (f32, f32), origin: (f32, f32), cell: (f32, f32), radius: f32, depth: f32 },
+    /// `color`, flat, everywhere inside `rect` that is OUTSIDE a periodic
+    /// field of rounded cells — the same field [`Prim::Lattice`] carves
+    /// (`period`, one cell centred at `origin`, each `cell` big with `radius`
+    /// corners), painted as grout rather than shaded. One draw for the whole
+    /// grid, with the cells' superellipse corners exact: what a graph's grid
+    /// lines are when the cells are the surface beneath showing through.
+    /// SDF path only — the legacy banded tessellation draws nothing.
+    Grout { rect: Rect, period: (f32, f32), origin: (f32, f32), cell: (f32, f32), radius: f32, color: [f32; 4] },
     /// Several rounded boxes carved (`raised` false) or raised (`raised`
     /// true) as ONE shape: the union of the boxes is the well, and its wall
     /// follows the union's outline — straddling it by ±`depth`/2 like every
@@ -1050,6 +1058,9 @@ impl PaintCtx {
             Prim::Lattice { rect, period, origin, cell, radius, depth } => {
                 self.lattice(rect, period, origin, cell, radius, depth)
             }
+            Prim::Grout { rect, period, origin, cell, radius, color } => {
+                self.grout(rect, period, origin, cell, radius, color)
+            }
             Prim::CarveUnion { boxes, depth, raised } => self.carve_union(boxes, depth, raised),
             Prim::Image { image, rect, alpha } => self.image(image, rect, alpha),
         }
@@ -1086,6 +1097,14 @@ impl PaintCtx {
         self.push(Prim::Lattice { rect, period, origin: (origin.0 + ox, origin.1 + oy), cell, radius, depth });
     }
 
+    /// Grout between a periodic field of rounded cells — see [`Prim::Grout`].
+    /// `origin` is any one cell's centre; `rect` bounds the paint.
+    pub fn grout(&mut self, rect: Rect, period: (f32, f32), origin: (f32, f32), cell: (f32, f32), radius: f32, color: [f32; 4]) {
+        let (ox, oy) = self.offset;
+        let rect = self.apply_offset(rect);
+        self.push(Prim::Grout { rect, period, origin: (origin.0 + ox, origin.1 + oy), cell, radius, color });
+    }
+
     /// Carve (or raise, with `raised`) the union of `boxes` as one shape with
     /// one wall — see [`Prim::CarveUnion`]. `depth` is the wall's run in px,
     /// as for [`PaintCtx::recess`].
diff --git a/src/vk/shader2d.wgsl b/src/vk/shader2d.wgsl
index 55c3d77..89ab916 100644
--- a/src/vk/shader2d.wgsl
+++ b/src/vk/shader2d.wgsl
@@ -156,6 +156,7 @@ const MODE_ROLL: i32 = 11;        // fill-less rolled perimeter, composited as a
 const MODE_DROPLET_SCRIM: i32 = 12; // flat feathered fill of the droplet silhouette
 const MODE_LATTICE: i32 = 13;     // periodic well field: nearest-cell carve, one evaluation
 const MODE_UNION: i32 = 14;       // union of feature boxes carved/raised as one wall
+const MODE_GROUT: i32 = 15;       // flat colour outside a periodic field of rounded cells
 // Fillet modes rejoin the shared free-carve path as their flat equivalents.
 const FILLET_TO_STEP: i32 = 4;    // 6 -> RECESS, 7 -> BOSS
 
@@ -385,6 +386,27 @@ fn plate_shade(frag: vec2f, vcol: vec4f) -> vec4f {
     // the surface tilt meets the half-vector, ~a third of the way out toward
     // the light) — and, like a plate face, the shade is expressed relative to
     // the flat face so the color at the lit center is exactly the app's.
+    // MODE_GROUT: the vertex colour, flat, everywhere OUTSIDE a periodic
+    // field of identical rounded cells — the grid lines of a graph whose
+    // cells are whatever lies beneath showing through, corners included.
+    // The same fold as MODE_LATTICE (p_rect = one cell's centre and
+    // half-extents, p_host.xy = the period, p_radii = the corner radius),
+    // but no lighting: coverage is the cell SDF's outside, 1px anti-aliased,
+    // so the cells' superellipse corners are exact and the whole grid is one
+    // draw. Flat strips could never paint the notch a rounded cell leaves at
+    // each crossing.
+    if (mode == MODE_GROUT) {
+        let per = max(rrect_clip.p_host.xy, vec2f(1e-3));
+        var gc = frag - rrect_clip.p_rect.xy;
+        gc = gc - per * round(gc / per);
+        let lg = rr_sdf_grad(gc, vec4f(0.0, 0.0, rrect_clip.p_rect.zw), rrect_clip.p_radii);
+        let cov = clamp(lg.z + 0.5, 0.0, 1.0);
+        if (cov <= 0.0) {
+            discard;
+        }
+        return vec4f(vcol.rgb, vcol.a * cov);
+    }
+
     if (mode == MODE_SPHERE) {
         let c = frag - rrect_clip.p_rect.xy;
         let r = max(rrect_clip.p_rect.z, 0.001);
diff --git a/src/widget/display/graph.rs b/src/widget/display/graph.rs
index 7ef79bd..f0b6431 100644
--- a/src/widget/display/graph.rs
+++ b/src/widget/display/graph.rs
@@ -387,15 +387,15 @@ impl Graph {
 
     /// The grid lines, flat, over whatever the graph is painted on — the
     /// pane plate: the cells are the plate showing through (no fill of their
-    /// own), and only the gaps are drawn, in the gap colour at the network
-    /// opacity, plus the origin axes as 2px lines down the rail centrelines
-    /// beside row and column 0. Painted per cell — right gap at the cell's
-    /// height, bottom gap at the full step width — so no two strips overlap
-    /// and a crossing carries one alpha, not two. Gated on the grid's
-    /// visibility only: `uniform_background` describes the widget's own
-    /// background fill and the designer hard-codes it true, which is how
-    /// its grid went undrawn until 2026-09-20. (A relief lattice was tried
-    /// in between and read too heavy at the DE's relief depth.)
+    /// own), and everything outside them is grout in the gap colour at the
+    /// network opacity — ONE [`Prim::Grout`] draw, so the cells' rounded
+    /// corners (the node corner radius, the DE's shared corner language) are
+    /// exact, notch at every crossing included. Plus the origin axes as 2px
+    /// lines down the rail centrelines beside row and column 0. Gated on the
+    /// grid's visibility only: `uniform_background` describes the widget's
+    /// own background fill and the designer hard-codes it true, which is how
+    /// its grid went undrawn until 2026-09-20. (Flat per-cell strips came
+    /// between: seamless, but square-cornered — no quad can paint the notch.)
     pub fn paint_grid(&self, rect: Rect, pc: &mut PaintCtx) {
         if self.grid_size_x <= 0.0 || self.grid_size_y <= 0.0 {
             return;
@@ -416,23 +416,14 @@ impl Graph {
         let step_y = self.grid_size_y + self.skipped_row_h;
         if self.show_network_grid && step_x >= 4.0 && step_y >= 4.0 {
             let gap_rgba = [self.gap_color[0], self.gap_color[1], self.gap_color[2], self.network_opacity];
-            let ry_start = (((rect.y - self.grid_origin_y) / step_y).floor() as i32 - 1).max(-100_000);
-            let ry_end = (((rect.y + rect.height - self.grid_origin_y) / step_y).ceil() as i32 + 1).min(100_000);
-            let cx_start = (((rect.x - self.grid_origin_x) / step_x).floor() as i32 - 1).max(-100_000);
-            let cx_end = (((rect.x + rect.width - self.grid_origin_x) / step_x).ceil() as i32 + 1).min(100_000);
-            // Rounded pixel boundaries, so adjacent strips meet without seams.
-            for r in ry_start..=ry_end {
-                let y_cell_start = (self.grid_origin_y + (r as f32) * step_y).round();
-                let y_cell_end = (self.grid_origin_y + (r as f32) * step_y + self.grid_size_y).round();
-                let y2 = (self.grid_origin_y + ((r + 1) as f32) * step_y).round();
-                for c in cx_start..=cx_end {
-                    let x_cell_start = (self.grid_origin_x + (c as f32) * step_x).round();
-                    let x_cell_end = (self.grid_origin_x + (c as f32) * step_x + self.grid_size_x).round();
-                    let x2 = (self.grid_origin_x + ((c + 1) as f32) * step_x).round();
-                    clipped(x_cell_end, y_cell_start, x2 - x_cell_end, y_cell_end - y_cell_start, gap_rgba, pc);
-                    clipped(x_cell_start, y_cell_end, x2 - x_cell_start, y2 - y_cell_end, gap_rgba, pc);
-                }
-            }
+            pc.grout(
+                rect,
+                (step_x, step_y),
+                (self.grid_origin_x + self.grid_size_x * 0.5, self.grid_origin_y + self.grid_size_y * 0.5),
+                (self.grid_size_x, self.grid_size_y),
+                self.cell_corner_radius(),
+                gap_rgba,
+            );
         }
 
         // Origin axes, in the gaps beside row/column 0.
diff --git a/tests/lattice_probe.rs b/tests/lattice_probe.rs
index a0321f7..00244e3 100644
--- a/tests/lattice_probe.rs
+++ b/tests/lattice_probe.rs
@@ -20,7 +20,7 @@ fn lattice_survives_tessellation_inside_a_clip() {
 }
 
 #[test]
-fn graph_emits_flat_grid_lines() {
+fn graph_emits_grout_and_axes() {
     use cce_ui::widget::GraphController;
     let mut g = cce_ui::widget::Graph::new();
     g.set_grid_sizes(71.0, 31.0);
@@ -33,8 +33,20 @@ fn graph_emits_flat_grid_lines() {
     let rect = Rect { x: 40.0, y: 40.0, width: 600.0, height: 300.0 };
     g.paint_grid(rect, &mut pc);
     let dl = pc.finish();
+    let n_grout = dl.items.iter().filter(|i| matches!(i.prim, Prim::Grout { .. })).count();
     let n_quad = dl.items.iter().filter(|i| matches!(i.prim, Prim::Quad { .. })).count();
-    // ~8 columns x ~10 rows of gap strips (two per cell) plus the two axes.
-    assert!(n_quad > 100, "only {n_quad} quads");
-    assert!(dl.items.iter().all(|i| matches!(i.prim, Prim::Quad { .. })), "flat lines only");
+    assert_eq!((n_grout, n_quad), (1, 2), "one grout draw + two axis lines");
+}
+
+#[test]
+fn grout_tessellates_to_a_mode_15_batch_in_its_colour() {
+    let mut pc = PaintCtx::new();
+    let clip = Rect { x: 40.0, y: 40.0, width: 600.0, height: 300.0 };
+    pc.clip(clip, |pc| pc.grout(clip, (85.0, 43.0), (60.0, 60.0), (71.0, 31.0), 15.5, [0.5, 0.5, 0.5, 0.1]));
+    let dl = pc.finish();
+    let (verts, batches, _, _) = cce_ui::backend::window_runner::tessellate_display_list(&dl, 1280.0, 720.0, 2.0);
+    let modes: Vec<f32> = batches.iter().filter_map(|b| b.plate.as_ref().map(|p| p.mode)).collect();
+    assert_eq!(modes, vec![15.0]);
+    assert_eq!(verts.len(), 6);
+    assert!(verts.iter().all(|v| (v.color[3] - 0.1).abs() < 1e-6), "cover quad carries the grout colour");
 }