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

commit8b38c324fd9ddbe670779daba7fe394f7804592d
parentc96cfe3717
authorLucas Galante <[email protected]>
date2026-08-14 20:53
fix: Groove degrades on the legacy path; one Prim forwarding match

Two problems, both introduced with Prim::Groove.

The banded path drew NOTHING for it. `bevel_shader 0` swaps the whole DE to
legacy vertex shading for A/B comparison, and every other relief primitive
degrades there rather than disappearing — Ridge has no bump profile in the
banded machinery, so it approximates with two steps and accepts the hot crest.
Groove just vanished, which took the breadcrumb's segment divisions with it:
the run plate rendered, undivided. It now draws the walls directly as two
feathered lines meeting at the centreline — the engraved-line fake, one half
shadowed and one lit, off the same light/light_sign/bevel_depth convention
push_bevel_edge_vertices_banded uses, so it re-lights with the DE's light
instead of hardcoding a side. Coarser than the SDF, which is what that path is.
Verified by flipping bevel_shader to 0: seams absent before, present after.

The forwarding match — a nested paint walk replaying a scratch list into the
real one — existed verbatim in cce-ui AND in cce-cloud's json_layout. Adding
Groove compiled against one and broke the other, caught only by a workspace
build. PaintCtx::replay is now the single place that has to learn a variant.
Text is returned rather than emitted, because the two callers legitimately
disagree: a subtree painter authored its own text and forwards it, everyone
else drops it for the own-labels bridge. #[must_use] so that choice stays
explicit.

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

 src/backend/window_runner.rs | 48 +++++++++++++++++++++++++++++++++----
 src/scene/paint.rs           | 56 ++++++++++++++++++++++++++++++++++++++++++++
 src/widget/model.rs          | 39 +++++++-----------------------
 3 files changed, 109 insertions(+), 34 deletions(-)

diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 77592ad..3b6eec4 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -1960,10 +1960,50 @@ pub fn tessellate_display_list(
                     shape: crate::layout::corner_shape(),
                 });
             }
-            // No legacy banded equivalent — the banded tessellators walk box
-            // edges, which is exactly the axis-aligned assumption a groove
-            // exists to escape. Same omission as `Ridge`/`ConcaveFillet`.
-            Prim::Groove { .. } => {}
+            Prim::Groove { a, b, width, depth, host: _ } => {
+                // Legacy approximation. The banded tessellators walk BOX edges —
+                // exactly the axis-aligned assumption a groove exists to escape —
+                // so the walls are drawn directly as two feathered lines meeting
+                // at the centerline: the engraved-line fake, one half in shadow
+                // and one lit. Coarser than the SDF (no profile curve, no host
+                // fade), but this path exists for A/B comparison, and drawing
+                // NOTHING would silently delete the mark rather than degrade it
+                // — see `Prim::Ridge` above, which accepts a hot crest for the
+                // same reason.
+                let (dx, dy) = (b.0 - a.0, b.1 - a.1);
+                let len = (dx * dx + dy * dy).sqrt();
+                if len < 0.001 {
+                    continue;
+                }
+                let n = (-dy / len, dx / len);
+                // Same convention as `push_bevel_edge_vertices_banded`: the
+                // light folded through `light_sign` (-1.0 — a groove is a
+                // carve), dotted with each wall's OUTWARD normal, amplitude on
+                // `bevel_depth`. So a groove re-lights with the DE's light
+                // instead of hardcoding which side is dark.
+                let rad = crate::layout::light_source_position();
+                let (lx, ly) = (-rad.cos(), rad.sin());
+                let v = crate::layout::bevel_depth() * (n.0 * lx + n.1 * ly);
+                // Each wall covers its own half, centreline to outer edge —
+                // abutting rather than overlapping. The SDF gets away with
+                // walls that overlap across a sub-pixel floor because it is one
+                // evaluation of |distance|; two opposite-signed overlays would
+                // just blend to mud.
+                let half = (*width * 0.5 + *depth * 0.5).max(0.5);
+                for side in [1.0f32, -1.0] {
+                    let sv = v * side;
+                    let c = if sv >= 0.0 { overlay_light(sv) } else { overlay_dark(sv) };
+                    if c[3] <= 0.0 {
+                        continue;
+                    }
+                    let off = side * half * 0.5;
+                    push_feathered_line_vertices(
+                        a.0 + n.0 * off, a.1 + n.1 * off,
+                        b.0 + n.0 * off, b.1 + n.1 * off,
+                        half, sw, sh, c, &mut verts,
+                    );
+                }
+            }
         }
         let end = verts.len() as u32;
         if end == start {
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index 12d52c2..7ee3631 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -451,6 +451,62 @@ impl PaintCtx {
         self.push(Prim::ConcaveFillet { cx: cx + ox, cy: cy + oy, radius, depth, start, raised });
     }
 
+    /// Re-emit an already-built [`Prim`] through this context, so it re-records the
+    /// current clip and translate state. This is the **single** place that has to
+    /// learn a new `Prim` variant: a nested paint walk builds a scratch list and
+    /// replays it into the real one, and that forwarding match used to exist
+    /// verbatim in two crates ([`crate::widget::model`] and cce-cloud's
+    /// `json_layout`) — adding `Prim::Groove` compiled against one and broke the
+    /// other, caught only by a full workspace build.
+    ///
+    /// [`Prim::Text`] is NOT emitted: it is returned untouched, because the two
+    /// callers disagree about it (a subtree painter authors its own text and wants
+    /// it forwarded; everyone else drops it in favour of the widget's own label
+    /// bridge). Every other variant is emitted and `None` comes back.
+    #[must_use = "a returned Text prim was not emitted — drop or forward it explicitly"]
+    pub fn replay(&mut self, prim: Prim) -> Option<Prim> {
+        match prim {
+            Prim::Text { .. } => return Some(prim),
+            Prim::Quad { rect, color } => self.quad(rect, color),
+            Prim::RoundedRect { rect, radius, corners, color } => {
+                self.rounded_rect(rect, radius, corners, color)
+            }
+            Prim::Border { rect, radii, fill, border, thickness } => {
+                self.border(rect, radii, fill, border, thickness)
+            }
+            Prim::Bevel { rect, radii, color, depth, tint } => {
+                self.bevel_tinted(rect, radii, color, depth, tint)
+            }
+            Prim::Recess { rect, radii, depth, edges, tint } => match tint {
+                Some(t) => self.recess_tinted(rect, radii, depth, t),
+                None => self.recess_edges(rect, radii, depth, edges),
+            },
+            Prim::Boss { rect, radii, depth, edges, tint } => match tint {
+                Some(t) => self.boss_edges_tinted(rect, radii, depth, edges, t),
+                None => self.boss_edges(rect, radii, depth, edges),
+            },
+            Prim::Ridge { rect, radii, depth, edges } => self.ridge_edges(rect, radii, depth, edges),
+            Prim::Plate { rect, radii, color, depth } => self.plate(rect, radii, color, depth),
+            Prim::Arc { cx, cy, radius, thickness, start, end, color } => {
+                self.arc(cx, cy, radius, thickness, start, end, color)
+            }
+            Prim::ArcShaded { cx, cy, radius, thickness, start, end, inner, crest, outer } => {
+                self.arc_shaded(cx, cy, radius, thickness, start, end, inner, crest, outer)
+            }
+            Prim::Vector { x1, y1, x2, y2, thickness, color, cap } => {
+                self.vector(x1, y1, x2, y2, thickness, color, cap)
+            }
+            Prim::Circle { cx, cy, radius, color } => self.circle(cx, cy, radius, color),
+            Prim::Sphere { cx, cy, radius, color } => self.sphere(cx, cy, radius, color),
+            Prim::ConcaveFillet { cx, cy, radius, depth, start, raised } => {
+                self.concave_fillet(cx, cy, radius, depth, start, raised)
+            }
+            Prim::Groove { a, b, width, depth, host } => self.groove(a, b, width, depth, host),
+            Prim::Image { image, rect, alpha } => self.image(image, rect, alpha),
+        }
+        None
+    }
+
     /// An engraved line from `a` to `b` cut into `host` — see [`Prim::Groove`].
     pub fn groove(&mut self, a: (f32, f32), b: (f32, f32), width: f32, depth: f32, host: Rect) {
         let (ox, oy) = self.offset;
diff --git a/src/widget/model.rs b/src/widget/model.rs
index 80654ed..f63a6cb 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -1258,37 +1258,16 @@ impl<W: Layout + Paint + Input + 'static> WidgetHost for Adapted<W> {
             if let Some(c) = clip_circle {
                 ctx.push_clip_circle(c);
             }
-            match item.prim {
-                Prim::Text { text, x, y, font_size, color, font, bounds, .. } if subtree => {
-                    ctx.text_with(text, x, y, font_size, color, font, bounds)
-                }
-                Prim::Text { .. } => {}
-                Prim::Quad { rect, color } => ctx.quad(rect, color),
-                Prim::RoundedRect { rect, radius, corners, color } => ctx.rounded_rect(rect, radius, corners, color),
-                Prim::Border { rect, radii, fill, border, thickness } => ctx.border(rect, radii, fill, border, thickness),
-                Prim::Bevel { rect, radii, color, depth, tint } => ctx.bevel_tinted(rect, radii, color, depth, tint),
-                Prim::Recess { rect, radii, depth, edges, tint } => match tint {
-                    Some(t) => ctx.recess_tinted(rect, radii, depth, t),
-                    None => ctx.recess_edges(rect, radii, depth, edges),
-                },
-                Prim::Boss { rect, radii, depth, edges, tint } => match tint {
-                    Some(t) => ctx.boss_edges_tinted(rect, radii, depth, edges, t),
-                    None => ctx.boss_edges(rect, radii, depth, edges),
-                },
-                Prim::Ridge { rect, radii, depth, edges } => ctx.ridge_edges(rect, radii, depth, edges),
-                Prim::Plate { rect, radii, color, depth } => ctx.plate(rect, radii, color, depth),
-                Prim::Arc { cx, cy, radius, thickness, start, end, color } => ctx.arc(cx, cy, radius, thickness, start, end, color),
-                Prim::ArcShaded { cx, cy, radius, thickness, start, end, inner, crest, outer } => {
-                    ctx.arc_shaded(cx, cy, radius, thickness, start, end, inner, crest, outer)
-                }
-                Prim::Vector { x1, y1, x2, y2, thickness, color, cap } => ctx.vector(x1, y1, x2, y2, thickness, color, cap),
-                Prim::Circle { cx, cy, radius, color } => ctx.circle(cx, cy, radius, color),
-                Prim::Sphere { cx, cy, radius, color } => ctx.sphere(cx, cy, radius, color),
-                Prim::ConcaveFillet { cx, cy, radius, depth, start, raised } => {
-                    ctx.concave_fillet(cx, cy, radius, depth, start, raised)
+            // `replay` emits every prim but Text and hands Text back — the two
+            // callers disagree about it. A subtree painter authored its own text
+            // (per-child fonts and clips) so that passes through verbatim;
+            // otherwise it is dropped in favour of the own-labels bridge below.
+            if let Some(Prim::Text { text, x, y, font_size, color, font, bounds, .. }) =
+                ctx.replay(item.prim)
+            {
+                if subtree {
+                    ctx.text_with(text, x, y, font_size, color, font, bounds);
                 }
-                Prim::Groove { a, b, width, depth, host } => ctx.groove(a, b, width, depth, host),
-                Prim::Image { image, rect, alpha } => ctx.image(image, rect, alpha),
             }
             if clip_circle.is_some() {
                 ctx.pop_clip_circle();