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

commitea3d35effd7dc447ab7ef47a0236fa51698eb67c
parent66ea2adc63
authorLucas Galante <[email protected]>
date2026-08-15 22:24
feat: port the plate branch; predict the edge roll (partly)

relief_shade now covers mode 1 as well as the free carves. The plate does
NOT composite like a carve and the difference is the point: a carve emits a
translucent overlay, a plate emits its own material as a MULTIPLY on its
fill plus an additive specular, expressed relative to the flat face so the
face keeps exactly the app's colour. That last property is now a test —
plate_face_is_untouched — because a plate that tints its own fill would be
a silent, DE-wide colour bug.

PLATE_CREST and ROLL_CUT join the constants the source-parsing test checks
against shader2d.wgsl, and analytic_roll_slope mirrors the truncated
superellipse quadrant.

Two real bugs fixed in the tool along the way:
- The predicted band drew NOTHING inside the face (f < 0), leaving it empty
  over most of its length and looking like the model failing, when the
  correct answer there is the untouched plate colour.
- The swatch was placed by a wall centre like a carve. A plate's roll runs
  inward from its SILHOUETTE, so it is placed by that instead — half a roll
  of misalignment otherwise — and its band is no longer backed by a full
  quad of plate colour, which had painted over the air past the silhouette
  and erased the very edge being shown.

HONEST STATE: the silhouette now aligns and the face matches, but the
ROLL's shading does not — predicted dips to 86 grey where the renderer
holds 106-109, ~6.6% of excursion concentrated entirely in the roll band.
The carve shapes are unaffected (still <1%). Leading hypothesis, untested:
the two are using different slope sources. `ProfileKnobs::custom` means
"the knobs were seeded from config", NOT "a custom roll LUT is installed in
the renderer" — with the identity sentinel in config those differ, and the
knob family (smoothstep) is nothing like the analytic roll (superellipse
quadrant). Gating on the installed profile rather than the seed flag is the
next thing to try.

 src/bin/cce-relief.rs     | 84 ++++++++++++++++++++++++++++++++++++++-----
 src/scene/relief_shade.rs | 91 +++++++++++++++++++++++++++++++++++++++++++----
 2 files changed, 160 insertions(+), 15 deletions(-)

diff --git a/src/bin/cce-relief.rs b/src/bin/cce-relief.rs
index 485009a..2b71cef 100644
--- a/src/bin/cce-relief.rs
+++ b/src/bin/cce-relief.rs
@@ -608,14 +608,22 @@ fn draw_section(pc: &mut PaintCtx, rect: Rect, profile: &ProfileKnobs, shape: Sh
         let walls = shape.walls();
         let light = relief_shade::light_vector();
         let mat = relief_shade::Material::from_style();
-        // The installed profile's slope, so the strip follows the knobs: the
-        // renderer differentiates the same height curve into its LUT.
-        let slope_at = |v: f32| -> f32 {
+        // Follow the knobs ONLY when a custom profile is actually installed.
+        // Until one is, the shader runs its analytic branch, and predicting
+        // from the knob curve instead quietly disagrees with it. The carve case
+        // hides this — the knob midpoints ARE the analytic smoothstep — but the
+        // roll's analytic form is a superellipse quadrant, nothing like the
+        // knob family, and there the two differ by ~20 grey levels.
+        let custom = profile.custom;
+        let knob_slope = |v: f32| -> f32 {
             let d = 1.0 / 32.0;
             let (a, b) = ((v - d * 0.5).clamp(0.0, 1.0), (v + d * 0.5).clamp(0.0, 1.0));
             let taper = (v.min(1.0 - v) * 32.0 * 0.667).clamp(0.0, 1.0);
             if b <= a { 0.0 } else { (profile.eval(b) - profile.eval(a)) / (b - a) * taper }
         };
+        let slope_at = |v: f32| -> f32 {
+            if custom { knob_slope(v) } else { relief_shade::analytic_carve_slope(v) }
+        };
         // The DE's own plate colour, NOT a swatch grey. The composite is
         // asymmetric — brightening screens toward white, darkening multiplies
         // toward black — so which lobe dominates depends on how light the
@@ -643,6 +651,48 @@ fn draw_section(pc: &mut PaintCtx, rect: Rect, profile: &ProfileKnobs, shape: Sh
             } else {
                 t_of(x)
             };
+            // The plate's edge roll is the OTHER shader branch: it emits its
+            // own material rather than an overlay, and it ends at a silhouette
+            // rather than a floor, so past f = 1 there is nothing to draw.
+            if matches!(shape, Shape::EdgeRoll) {
+                let roll_slope_at = |f: f32| -> f32 {
+                    if !custom {
+                        return relief_shade::analytic_roll_slope(f);
+                    }
+                    let d = 1.0 / 32.0;
+                    let (a, b) = ((f - d * 0.5).clamp(0.0, 1.0), (f + d * 0.5).clamp(0.0, 1.0));
+                    // Taper at the FACE end only; the silhouette keeps whatever
+                    // slope the curve was drawn ending on (roll_slope's `win`).
+                    let taper = (f * 32.0 * 0.667).clamp(0.0, 1.0);
+                    if b <= a { 0.0 } else { (profile.eval(b) - profile.eval(a)) / (b - a) * taper }
+                };
+                // Past the silhouette (f > 1) there is nothing; INSIDE the
+                // face (f < 0) there is the plate's own colour, untouched —
+                // that is the whole point of expressing plate shading relative
+                // to the flat face. Drawing nothing there, as the first cut
+                // did, leaves the band empty over most of its length and looks
+                // like the model failing.
+                //
+                // The section draws this shape with the face on the plateau
+                // side and AIR past the wall — so the outward normal at the
+                // drawn silhouette points along +x, which is the direction fed
+                // to the model here.
+                let c = if t < 0.0 {
+                    Some(surface)
+                } else {
+                    relief_shade::plate_surface(surface, t, [1.0, 0.0], &roll_slope_at, light, &mat)
+                };
+                if let Some(c) = c {
+                    let band = if horiz {
+                        Rect { x: x_l, y: x, width: x_r - x_l, height: step.min(scan_hi - x) }
+                    } else {
+                        Rect { x, y: strip_y, width: step.min(scan_hi - x), height: strip_h }
+                    };
+                    pc.quad(band, [c[0], c[1], c[2], 1.0]);
+                }
+                x += step;
+                continue;
+            }
             let mut c = surface;
             for (mode, w0, w1) in &walls {
                 let u = if horiz { t } else { (t - w0) / (w1 - w0) };
@@ -689,10 +739,17 @@ fn draw_section(pc: &mut PaintCtx, rect: Rect, profile: &ProfileKnobs, shape: Sh
                 // over the near-black opening that lands ~4 grey levels below
                 // the predicted band, which then reads as a constant model
                 // error it is not. The two bands must differ ONLY by shading.
-                pc.quad(
-                    Rect { x: x_l, y: swatch_y, width: x_r - x_l, height: strip_h },
-                    [plate[0], plate[1], plate[2], 1.0],
-                );
+                // A carve needs a surface to cut into; a PLATE brings its own
+                // fill and ends at a silhouette. Backing the plate with a full
+                // band of its own colour paints over the air past that
+                // silhouette, so the band reads as uniform material and the
+                // roll vanishes — the swatch has to be left empty for it.
+                if !matches!(shape, Shape::EdgeRoll) {
+                    pc.quad(
+                        Rect { x: x_l, y: swatch_y, width: x_r - x_l, height: strip_h },
+                        [plate[0], plate[1], plate[2], 1.0],
+                    );
+                }
                 // The box is placed so the SELECTED wall is the one crossing
                 // the band, and its other three edges are pushed far outside
                 // it. For a horizontal wall the box spans the band's width and
@@ -745,14 +802,23 @@ fn draw_section(pc: &mut PaintCtx, rect: Rect, profile: &ProfileKnobs, shape: Sh
                         );
                         pc.boss(inner, sq, unit);
                     }
-                    Shape::EdgeRoll => pc.plate(tall, sq, plate, unit),
+                    // A plate's roll runs INWARD from its silhouette, where a
+                    // carve's wall straddles its edge — so this box is placed
+                    // by its silhouette at the section's x1, not by a wall
+                    // centre at edge_x. Half a roll of misalignment otherwise.
+                    Shape::EdgeRoll => pc.plate(
+                        Rect { x: x1 - 4000.0, y: swatch_y - 400.0, width: 4000.0, height: strip_h + 800.0 },
+                        sq,
+                        [plate[0], plate[1], plate[2], 1.0],
+                        unit,
+                    ),
                 }
             },
         );
 
         if walls.is_empty() {
             pc.text_with(
-                "plate branch — predicted band is blank; the swatch is real".to_string(),
+                "".to_string(),
                 x_l + 4.0,
                 strip_y + 1.0,
                 10.0,
diff --git a/src/scene/relief_shade.rs b/src/scene/relief_shade.rs
index 3f559f6..501a7ae 100644
--- a/src/scene/relief_shade.rs
+++ b/src/scene/relief_shade.rs
@@ -1,6 +1,9 @@
-//! The relief shading model in Rust — the arithmetic `shader2d.wgsl`'s
-//! free-carve branch performs per pixel, so code outside the GPU can PREDICT
-//! the pixels instead of sketching them.
+//! The relief shading model in Rust — the arithmetic `shader2d.wgsl` performs
+//! per pixel, so code outside the GPU can PREDICT the pixels instead of
+//! sketching them. Both branches: the free carves ([`carve_shade`]) and the
+//! plate's own perimeter roll ([`plate_surface`]), which do not composite the
+//! same way — a carve is a translucent overlay, a plate is a multiply on its
+//! own fill plus an additive specular.
 //!
 //! This exists because `cce-relief` drew its cross-sections with a stand-in
 //! (`lit = dot(normal, light) * 0.35`) that shares nothing with the shader but
@@ -19,9 +22,15 @@ pub const PLATE_AMBIENT: f32 = 0.55;
 /// Recess depth as a fraction of the roll width. Mirrors `RECESS_DEPTH`.
 pub const RECESS_DEPTH: f32 = 0.6;
 
-/// The free-carve modes, matching the shader's `MODE_*` for the branch this
-/// module reproduces. The plate's own perimeter roll (mode 1) is a different
-/// branch and is deliberately not modelled here.
+/// Amplitude of the bright crest hugging a raised plate's silhouette.
+/// Mirrors `PLATE_CREST`.
+pub const PLATE_CREST: f32 = 0.25;
+/// The roll's descent is truncated at this fraction of the quadrant, so the
+/// profile ends on a bounded slope instead of plunging vertical at the
+/// silhouette. Mirrors `ROLL_CUT`.
+pub const ROLL_CUT: f32 = 0.8;
+
+/// The free-carve modes, matching the shader's `MODE_*`.
 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
 pub enum CarveMode {
     Recess,
@@ -157,6 +166,61 @@ pub fn carve_shade(
     (diff / flat_shade(light) - 1.0 + curv + spec) * mat.strength
 }
 
+/// The analytic roll slope at `f` (0 where the roll meets the face, 1 at the
+/// silhouette). Mirrors `roll_slope`'s analytic branch: the one-exponent
+/// generalisation of the circular quadrant, truncated at [`ROLL_CUT`].
+pub fn analytic_roll_slope(f: f32) -> f32 {
+    let shape = crate::layout::corner_shape();
+    let fc = f * ROLL_CUT;
+    if shape > 2.001 {
+        let h = (1.0 - fc.powf(shape)).max(1e-4).powf(1.0 / shape);
+        (fc / h).powf(shape - 1.0)
+    } else {
+        fc / (1.0 - fc * fc).max(1e-4).sqrt()
+    }
+}
+
+/// The plate's own surface colour across its perimeter roll — mode 1, which is
+/// NOT the free-carve branch and does not composite like one.
+///
+/// A carve emits a translucent overlay; a plate emits its material directly, as
+/// a MULTIPLY on the face colour plus an additive specular. Expressed relative
+/// to the flat face (shade 1.0, specular 0.0) so the face keeps exactly the
+/// app's chosen colour — which is why a plate can be tinted freely and a carve
+/// cannot.
+///
+/// `f` is 0 where the roll meets the face and 1 at the silhouette. Returns
+/// `None` past the silhouette, where the shader discards.
+pub fn plate_surface(
+    base: [f32; 3],
+    f: f32,
+    facing: [f32; 2],
+    roll_slope_at: &dyn Fn(f32) -> f32,
+    light: [f32; 3],
+    mat: &Material,
+) -> Option<[f32; 3]> {
+    if !(0.0..=1.0).contains(&f) {
+        return None;
+    }
+    let slope = roll_slope_at(f);
+    let sv = [facing[0] * slope, facing[1] * slope];
+    let n = {
+        let len = (sv[0] * sv[0] + sv[1] * sv[1] + 1.0).sqrt();
+        [sv[0] / len, sv[1] / len, 1.0 / len]
+    };
+    let ndl = (n[0] * light[0] + n[1] * light[1] + n[2] * light[2]).max(0.0);
+    let diff = PLATE_AMBIENT + (1.0 - PLATE_AMBIENT) * ndl;
+    // The crest: the ambient-catching convex rim that makes glass read as glass.
+    let extra = PLATE_CREST * f * f * f;
+    let shade = 1.0 + (diff / flat_shade(light) - 1.0 + extra) * mat.strength;
+    let spec = roll_spec(sv, light, mat) * mat.strength;
+    Some([
+        (base[0] * shade + spec).clamp(0.0, 1.0),
+        (base[1] * shade + spec).clamp(0.0, 1.0),
+        (base[2] * shade + spec).clamp(0.0, 1.0),
+    ])
+}
+
 /// Composite one carve's shading over what is already there, the way the
 /// renderer's alpha blend does.
 ///
@@ -196,6 +260,21 @@ mod tests {
     fn constants_match_the_shader() {
         assert_eq!(wgsl_const("PLATE_AMBIENT"), PLATE_AMBIENT);
         assert_eq!(wgsl_const("RECESS_DEPTH"), RECESS_DEPTH);
+        assert_eq!(wgsl_const("PLATE_CREST"), PLATE_CREST);
+        assert_eq!(wgsl_const("ROLL_CUT"), ROLL_CUT);
+    }
+
+    /// The plate's face must come through as exactly the app's colour, or a
+    /// plate silently recolours whatever it is filled with.
+    #[test]
+    fn plate_face_is_untouched() {
+        let light = light_vector();
+        let mat = Material::from_style();
+        let base = [0.3f32, 0.4, 0.5];
+        let out = plate_surface(base, 0.0, [-1.0, 0.0], &analytic_roll_slope, light, &mat).unwrap();
+        for i in 0..3 {
+            assert!((out[i] - base[i]).abs() < 1e-4, "face channel {i}: {} vs {}", out[i], base[i]);
+        }
     }
 
     /// A flat surface must composite to nothing, or the cover quad tints