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

commit949e35e80a945d14a2aea19e417575c348e61903
parent9d93646502
authorLucas Galante <[email protected]>
date2026-08-14 21:12
feat: CCE_PLATE_DEBUG — say which carves group and which fall back

A carve either becomes a CSG feature of its host plate's single draw or falls
back to standalone overlay shading, and the two do not look the same: grouped,
its wall meets the plate's rolled perimeter as a real junction; fallen back, the
host-box fade approximates that. Six rules decide it, three of them dynamic
(draw order, neighbouring plates, whether another carve already claimed the
host's feature run) — so the same widget can shade either way depending on what
is around it, with nothing on stderr saying which happened. It has shipped as a
bug once already: a hovered button's opaque fill severed every later button from
the backplate they carve into, which is why plate_stack is a stack.

Setting CCE_PLATE_DEBUG=1 prints a per-frame count plus, for every fallback,
the rect and the rule that rejected it by name. Off by default, read once, and
the classification only runs when set — the hot path is untouched. Verified
observation-only: cce-files renders byte-identical with it off.

What it says about the code today, on three apps: NO misgrouping. Every fallback
is a documented rule firing correctly. But grouping turns out to be rare — 2 of
10 carves in cce-ui's own demo, 0 of 7 in cce-files — because any ordinary
geometry painted after a plate closes its grouping window, which apps do
constantly by interleaving flat fills with reliefs. The exact-CSG path most of
this machinery exists to serve almost never runs in a real client.

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

 CLAUDE.md                    | 18 ++++++++++
 src/backend/window_runner.rs | 86 ++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 104 insertions(+)

diff --git a/CLAUDE.md b/CLAUDE.md
index 7623f51..78c8be6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -154,3 +154,21 @@ need no text dependency of their own). Bundled fonts load from `$CCE_FONTS_DIR`
 System fonts are loaded only when `$CCE_LOAD_SYSTEM_FONTS` is set (or via
 `create_font_system_with_system_fonts()`, used by the font picker). Configured custom font
 families are validated at startup with a warning if missing.
+
+## Debug environment variables
+
+All opt-in, all read once, all quiet when unset — set one and run any client.
+
+- `CCE_PLATE_DEBUG=1` — per frame, how many relief carves grouped into their host plate
+  as exact CSG features vs fell back to standalone overlay shading, and for each fallback
+  **why** (one of six rules: ridge, edge-suppressed, tinted, feature budget, no enclosing
+  plate, host's feature run closed). The two paths shade junctions differently, and three
+  of those rules are dynamic, so this is the answer to "why does this widget's carve look
+  different here?". Note what it reveals: grouping is *rare* — the reference demo groups
+  2 of 10, cce-files 0 of 7, because any ordinary geometry painted after a plate closes
+  its grouping window (correctly — the carve's shading is baked into the plate's earlier
+  draw).
+- `CCE_PRESENT_DEBUG=1` — swapchain present/acquire tracing.
+- `CCE_VK_DEVICE=<substring>` — force a physical device; `CCE_VK_RT=0` disables ray tracing.
+- `CCE_FORCE_SCALE=<f>` — override HiDPI scale detection.
+- `CCE_UI_FAULT_RECONNECT=1` — exercise the Wayland reconnect path.
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 3b6eec4..521b4a7 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -1500,6 +1500,25 @@ pub struct DlImage {
 /// logical surface dimensions (as everywhere else); `scale` is the HiDPI factor, needed because an
 /// item's circular clip rides the vertices in PHYSICAL pixels. Consecutive prims sharing a clip are
 /// merged into one batch (the circle clip is per-vertex, so it never splits batches).
+/// `CCE_PLATE_DEBUG=1` — trace which carves group into their host plate as exact
+/// CSG features and which fall back to the standalone overlay shading.
+///
+/// The two paths do NOT look the same: a grouped carve is part of the plate's
+/// single height field, so its wall meets the plate's rolled perimeter as a real
+/// junction, while the fallback approximates that with the host-box fade. Six
+/// conditions decide it, three of them dynamic (draw order, neighbouring plates,
+/// whether another carve already claimed the host's feature run), so the SAME
+/// widget can render either way depending on what is around it — and it does so
+/// silently. That has already shipped as a bug once: a hovered button's opaque
+/// fill used to sever every later button from the backplate they carve into,
+/// which is why `plate_stack` is a stack (see its comment below).
+///
+/// Off by default and read once; the classification below runs only when set.
+fn plate_debug() -> bool {
+    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
+    *ON.get_or_init(|| std::env::var("CCE_PLATE_DEBUG").is_ok_and(|v| v != "0"))
+}
+
 pub fn tessellate_display_list(
     dl: &crate::scene::paint::DisplayList,
     sw: f32,
@@ -1527,6 +1546,10 @@ pub fn tessellate_display_list(
     // plate may only receive MORE features while no other plate has appended
     // any since.
     let mut last_feature_plate: Option<usize> = None;
+    // `CCE_PLATE_DEBUG` bookkeeping — see `plate_debug`.
+    let dbg_plates = plate_debug();
+    let mut dbg_grouped = 0usize;
+    let mut dbg_fell_back: Vec<String> = Vec::new();
 
     // SDF-lit plate path (shader2d's plate branch) vs the legacy banded vertex
     // shading, plus the frame-constant lighting inputs it pushes per plate.
@@ -1686,6 +1709,58 @@ pub fn tessellate_display_list(
                 } else {
                     None
                 };
+                if dbg_plates {
+                    match host_plate {
+                        Some(_) => dbg_grouped += 1,
+                        None => {
+                            // Re-derive WHY, in the same order the guard tests
+                            // them. Debug-only: the hot path above is untouched.
+                            let kind = match &item.prim {
+                                Prim::Boss { .. } => "boss",
+                                Prim::Ridge { .. } => "ridge",
+                                _ => "recess",
+                            };
+                            let infl = *depth * 0.5 + 2.0;
+                            let (sx0, sy0) = (rect.x - infl, rect.y - infl);
+                            let (sx1, sy1) = (rect.x + rect.width + infl, rect.y + rect.height + infl);
+                            let enclosing: Vec<usize> = plate_stack
+                                .iter()
+                                .enumerate()
+                                .filter(|(_, (_, p))| {
+                                    rect.x >= p.x - 0.5
+                                        && rect.y >= p.y - 0.5
+                                        && rect.x + rect.width <= p.x + p.width + 0.5
+                                        && rect.y + rect.height <= p.y + p.height + 0.5
+                                })
+                                .map(|(si, _)| si)
+                                .collect();
+                            let occluded = |si: usize| {
+                                plate_stack[si + 1..].iter().any(|(_, o)| {
+                                    sx0 < o.x + o.width && sx1 > o.x && sy0 < o.y + o.height && sy1 > o.y
+                                })
+                            };
+                            let why = if mode >= 3.5 {
+                                "ridge — never groups (its bump profile is not a monotonic step)".into()
+                            } else if !full_ring {
+                                format!("edge-suppressed {edges:?} — the extended wall would smear across the host")
+                            } else if tint.is_some() {
+                                "tinted — a CSG feature is geometry only, it carries no color".into()
+                            } else if features.len() >= crate::vk::MAX_PLATE_FEATURES {
+                                format!("feature budget full ({} used)", features.len())
+                            } else if enclosing.is_empty() {
+                                format!("no enclosing plate ({} open)", plate_stack.len())
+                            } else if enclosing.iter().all(|&si| occluded(si)) {
+                                "a later plate overlaps this carve's shaded region".into()
+                            } else {
+                                "host plate's feature run is closed (another carve appended since)".into()
+                            };
+                            dbg_fell_back.push(format!(
+                                "  overlay: {kind} ({:.0},{:.0} {:.0}x{:.0}) — {why}",
+                                rect.x, rect.y, rect.width, rect.height
+                            ));
+                        }
+                    }
+                }
                 if let Some(bi) = host_plate {
                     {
                         // A wall the carve shares with the plate's edge extends
@@ -2041,6 +2116,17 @@ pub fn tessellate_display_list(
         }
     }
 
+    if dbg_plates && (dbg_grouped > 0 || !dbg_fell_back.is_empty()) {
+        eprintln!(
+            "plate-dbg: {} carves — {dbg_grouped} grouped (exact CSG), {} overlay fallback",
+            dbg_grouped + dbg_fell_back.len(),
+            dbg_fell_back.len(),
+        );
+        for line in &dbg_fell_back {
+            eprintln!("plate-dbg: {line}");
+        }
+    }
+
     (verts, batches, images, features)
 }