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

commitec7c36d4821672837a1840c68dbb4e7f8d9367f3
parent922e6f0a05
authorLucas Galante <[email protected]>
date2026-08-15 20:37
feat: cce-relief predicts pixels — the real shading model, shared

The section drew its lighting with a stand-in: `lit = dot(n, l) * 0.35`,
sharing nothing with the shader but a light azimuth. That draws geometry
honestly and shading not at all, so the tool could not answer the question
you most often open it to ask — does this wall read hot.

scene::relief_shade is the free-carve branch of shader2d.wgsl in Rust:
carve_slope, roll_spec, the ambient/diffuse/specular/curvature terms, and
the alpha composite. cce-relief now runs each of a shape's carves through
it and composites them IN EMISSION ORDER into a strip under the section, so
a composed shape gets one pass per carve exactly as it would in a frame.
The section says what the shape is; the strip says what it will look like.

Drift is the hazard, since WGSL and Rust cannot share a function body.
Three defences, in descending order of how much they actually buy:
- The light vector and material now live in relief_shade and the RENDERER
  reads them from there (window_runner's plate_light/plate_mat). One
  definition, not two that agree today.
- A unit test parses PLATE_AMBIENT and RECESS_DEPTH out of the shader's own
  source text and asserts the Rust constants match.
- Property tests: flat ground must composite to exactly zero (or the cover
  quad tints everything it covers), and ridge/trough must oppose where the
  wall is steep.

That last test failed as first written, and the assertion was wrong rather
than the model: the response has an EVEN component. Tilting a surface
either way shortens the face-on n.z term and adds a non-negative specular,
so near the plateau lips a ridge and a trough BOTH darken slightly. It is
now asserted only where the wall is steep, with the reason written down —
that faint lip lobe is real, and I had already puzzled over it in pixel
measurements without recognising it.

The strip composites over page_low_color, not a swatch grey, because the
composite is asymmetric: brightening screens toward white, darkening
multiplies toward black, so which lobe dominates depends on the base and
INVERTS between a dark plate and a light one. Over my first (pale) base the
strip reported dark-dominant where the real frame is bright-dominant ~4:1.
Measured on the DE's plate it now reads +85/-25, matching the +91/-20 taken
off a real rendering.

Also fixed, found while wiring this: the knob row was laid out on
`profile_dropdown.selected == 0`, which was right when index 0 WAS the wall
profile and wrong the moment the picker listed shapes — layout positioned
one knob set while paint drew the other, parking all three sliders
off-screen. Both sides now read active_shape()/active_curve().

 src/backend/window_runner.rs |  11 +-
 src/bin/cce-relief.rs        | 153 +++++++++++++++++++++++++--
 src/scene/mod.rs             |   1 +
 src/scene/relief_shade.rs    | 242 +++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 392 insertions(+), 15 deletions(-)

diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 6b506f8..b320dff 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -1574,17 +1574,16 @@ pub fn tessellate_display_list(
     // SDF-lit plate path (shader2d's plate branch) vs the legacy banded vertex
     // shading, plus the frame-constant lighting inputs it pushes per plate.
     let shader_plates = crate::layout::bevel_shader();
-    let plate_light = {
-        let az = crate::layout::light_source_position();
-        let el = std::f32::consts::FRAC_PI_4; // light elevation above the screen plane
-        [az.cos() * el.cos(), -az.sin() * el.cos(), el.sin()]
-    };
+    // Light and material come from `scene::relief_shade`, which is also what
+    // cce-relief predicts pixels with — one definition, so the editor cannot
+    // draw a different material than the renderer applies.
+    let plate_light = crate::scene::relief_shade::light_vector();
     // [shading strength (1.0 at the default bevel_depth), specular strength,
     // shininess, curvature/AO strength] — the plastic material. Curvature is
     // kept near the raised path's crest amplitude: the recess shoulder's
     // brightening lands on the same pixels as its specular line, and the two
     // stack — at 0.5 the step read several times hotter than a plate roll.
-    let plate_mat = [crate::layout::bevel_depth() / 0.15, 0.4, 24.0, 0.2];
+    let plate_mat = crate::scene::relief_shade::Material::from_style().to_array();
 
     for item in &dl.items {
         let start = verts.len() as u32;
diff --git a/src/bin/cce-relief.rs b/src/bin/cce-relief.rs
index 28d6814..23f046f 100644
--- a/src/bin/cce-relief.rs
+++ b/src/bin/cce-relief.rs
@@ -9,7 +9,16 @@
 //! reserves "bevel" for the `Bevel` prim and the shared edge treatment, and
 //! calls the family relief primitives — which is also what `control_relief`
 //! gates and what the `relief` config node is called. This was `cce-bevel`
-//! until the shape picker landed and made the mismatch untenable. Every edit applies live to this process (the
+//! until the shape picker landed and made the mismatch untenable.
+//!
+//! Under the section runs the SHADING STRIP: the same shape put through
+//! `scene::relief_shade`, the shader's own arithmetic in Rust, composited one
+//! carve at a time in emission order. The section says what the shape is; the
+//! strip says what it will look like. A composed shape gets one pass per carve
+//! there, which is how a stack whose geometry looks reasonable can still read
+//! hot.
+//!
+//! Every edit applies live to this process (the
 //! popup's own plate, wells, and buttons ARE the preview) and logs the
 //! sampled spec to stdout; Save persists to `~/.config/cce/config.kdl`
 //! (`style.surface.relief`) so every cce app starts with the material —
@@ -28,6 +37,7 @@ use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, Win
 use cce_ui::layout::RELIEF_PROFILE_IDENTITY_SPEC as IDENTITY_SPEC;
 use cce_ui::scene::layout::Rect;
 use cce_ui::scene::paint::{Cap, DisplayList, PaintCtx};
+use cce_ui::scene::relief_shade::{self, CarveMode};
 use cce_ui::widget::{
     Adapted, Button, Dropdown, ElementState, Event, KeyEvent, MouseButton, MouseScrollDelta,
     Slider, WidgetHost, WidgetId,
@@ -56,6 +66,9 @@ const GUTTER_L: f32 = 34.0;
 const GUTTER_B: f32 = 16.0;
 const MIN_BAND: f32 = 36.0;
 const UNDERSIDE: f32 = 18.0;
+/// Height of the shading strip under the section — the band that shows what
+/// the shape will actually LOOK like, as opposed to what it is.
+const SHADE_STRIP_H: f32 = 14.0;
 
 #[derive(Debug, Clone)]
 enum BevelMsg {
@@ -202,6 +215,34 @@ impl Shape {
         })
     }
 
+    /// The carve(s) this shape emits, as (mode, run start, run end). The
+    /// shading strip composites these IN ORDER, exactly as the renderer
+    /// composites one prim's cover quad over another's — which is the whole
+    /// reason a stacked shape can read hot while its geometry looks fine.
+    ///
+    /// The plate's edge roll returns nothing: it is the shader's plate branch
+    /// (fill + roll + CSG features), not the free-carve branch this models, so
+    /// the strip says so rather than inventing a number.
+    fn walls(self) -> Vec<(CarveMode, f32, f32)> {
+        match self {
+            Shape::Recess => vec![(CarveMode::Recess, 0.0, 1.0)],
+            Shape::Boss => vec![(CarveMode::Boss, 0.0, 1.0)],
+            Shape::Ridge => vec![(CarveMode::Ridge, 0.0, 1.0)],
+            Shape::Trough | Shape::InsetPlate => vec![(CarveMode::Trough, 0.0, 1.0)],
+            // The groove rejoins the free-carve path as a recess on |distance to
+            // the line| - halfwidth; across the section that is a trough spread
+            // over the wider run.
+            Shape::Groove => vec![(CarveMode::Trough, 0.0, 1.0 + GROOVE_FLOOR)],
+            // The fillet rejoins the shared path as its flat equivalent.
+            Shape::Fillet => vec![(CarveMode::Recess, 0.0, 1.0)],
+            Shape::EdgeRoll => Vec::new(),
+            // The pre-35f5183 pair, in emission order.
+            Shape::InsetStacked => {
+                vec![(CarveMode::Recess, 0.0, 1.0), (CarveMode::Boss, 0.5, 1.5)]
+            }
+        }
+    }
+
     /// For a composed shape, the run interval where TWO walls are live at once
     /// — the band that gets two lighting evaluations instead of one, and so the
     /// band that reads hot. `None` for a primitive shape, which has one wall
@@ -361,8 +402,14 @@ fn draw_section(pc: &mut PaintCtx, rect: Rect, profile: &ProfileKnobs, shape: Sh
     let x_l = rect.x + m + GUTTER_L;
     let x_r = rect.x + rect.width - m;
     let avail_w = x_r - x_l;
+    // The shading strip owns a reserved band along the bottom of the opening,
+    // and the SECTION lays out in what is left. Reserving it up front is what
+    // keeps the slab from expanding over it — the slab grows to the bottom of
+    // whatever area it is given.
+    let strip_band = SHADE_STRIP_H + 4.0;
+    let sec_h = rect.height - strip_band;
     // stroke + slab-underside room, plus the bottom gutter
-    let avail_h = rect.height - 2.0 * m - UNDERSIDE - GUTTER_B;
+    let avail_h = sec_h - 2.0 * m - UNDERSIDE - GUTTER_B;
 
     let run = shape.run();
     // Vertical extent of THIS shape, sampled — a ridge lives above the surface,
@@ -388,7 +435,7 @@ fn draw_section(pc: &mut PaintCtx, rect: Rect, profile: &ProfileKnobs, shape: Sh
     // y of h = 0 (the surrounding surface), placed so the whole excursion fits.
     let drawn_h = h_span * unit;
     let y_zero = rect.y
-        + ((rect.height - drawn_h - UNDERSIDE - GUTTER_B) / 2.0).max(m)
+        + ((sec_h - drawn_h - UNDERSIDE - GUTTER_B) / 2.0).max(m)
         - h_lo * unit;
     let y_top = y_zero + h_lo * unit;
     let y_bot = y_zero + h_hi * unit;
@@ -459,7 +506,7 @@ fn draw_section(pc: &mut PaintCtx, rect: Rect, profile: &ProfileKnobs, shape: Sh
     slab = [slab[0] * 1.25 + 0.03, slab[1] * 1.25 + 0.03, slab[2] * 1.25 + 0.03, 1.0];
     // A fixed slab thickness under the lowest surface, so vertical centering
     // doesn't grow a bottomless block of material.
-    let slab_bot = (y_bot + 16.0).min(rect.y + rect.height - 8.0);
+    let slab_bot = (y_bot + 16.0).min(rect.y + sec_h - 8.0);
     let step = 2.0f32;
     let mut x = x_l;
     while x < x_r {
@@ -488,6 +535,76 @@ fn draw_section(pc: &mut PaintCtx, rect: Rect, profile: &ProfileKnobs, shape: Sh
         rt += 0.25;
     }
 
+    // THE SHADING STRIP: what the shader will actually put on screen along
+    // this section, as opposed to the geometry drawn above it.
+    //
+    // Each of the shape's carves is evaluated with the real model and
+    // composited in emission order onto the surface colour, so a shape that
+    // emits two overlapping walls gets two passes here exactly as it would in
+    // the frame. That is the difference the geometry cannot show: the stacked
+    // inset's dip is only half again as deep as the trough's, but its strip is
+    // visibly hotter, because the overlap region is lit twice.
+    let strip_h = SHADE_STRIP_H;
+    let strip_y = rect.y + rect.height - strip_band + 2.0;
+    {
+        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 {
+            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 }
+        };
+        // 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
+        // surface under it is, and it INVERTS between a dark plate and a light
+        // one. Drawn over the wrong base the strip reverses the very thing you
+        // came to judge: on this plate a wall's bright side out-measures its
+        // dark side about 4:1, and over a pale swatch it reads the other way.
+        let plate = cce_ui::color::page_low_color();
+        let surface = [plate[0], plate[1], plate[2]];
+        let step = 1.0f32;
+        let mut x = x_l;
+        while x < x_r {
+            let t = (x - x0) / unit;
+            let mut c = surface;
+            for (mode, w0, w1) in &walls {
+                let u = (t - w0) / (w1 - w0);
+                if !(-0.02..=1.02).contains(&u) {
+                    continue;
+                }
+                // Facing: the SDF gradient along the section, pointing out of
+                // the carve. The section is drawn descending to the right, so
+                // that is -x. The OTHER three walls of a real rect face other
+                // ways and shade differently — the same profile reads brighter
+                // on one side of a control than the other, which is why a
+                // seam's two rims never match.
+                let v = relief_shade::carve_shade(*mode, u, [-1.0, 0.0], &slope_at, light, &mat);
+                c = relief_shade::composite(c, v);
+            }
+            pc.quad(
+                Rect { x, y: strip_y, width: step.min(x_r - x), height: strip_h },
+                [c[0], c[1], c[2], 1.0],
+            );
+            x += step;
+        }
+        if walls.is_empty() {
+            pc.text_with(
+                "plate branch — not the free-carve model".to_string(),
+                x_l + 4.0,
+                strip_y + 1.0,
+                10.0,
+                num_color,
+                Some("monospace".to_string()),
+                None,
+            );
+        }
+    }
+
     // The surface stroke, lit per segment: outward normal (material below)
     // against the DE light azimuth — the same light the real walls shade by.
     let az = cce_ui::layout::light_source_position();
@@ -524,6 +641,20 @@ fn draw_section(pc: &mut PaintCtx, rect: Rect, profile: &ProfileKnobs, shape: Sh
 }
 
 impl BevelPopup {
+    /// The selected shape. The dropdown index is the ONLY source; read it
+    /// through here so layout and paint cannot disagree about it.
+    fn active_shape(&self) -> Shape {
+        Shape::ALL
+            .get(self.profile_dropdown.selected)
+            .copied()
+            .unwrap_or(Shape::Recess)
+    }
+
+    /// The curve the knobs are editing for the selected shape.
+    fn active_curve(&self) -> Curve {
+        self.active_shape().curve()
+    }
+
     fn root_ids(&self) -> [WidgetId; 11] {
         [
             self.profile_dropdown.id(),
@@ -860,6 +991,8 @@ impl Application for BevelPopup {
             let natural = (w - 2.0 * CUT_MARGIN - GUTTER_L - 2.0 * MIN_BAND)
                 + 2.0 * CUT_MARGIN
                 + UNDERSIDE
+                + GUTTER_B
+                + SHADE_STRIP_H
                 + GUTTER_B;
             let cut_h = (self.height as f32 - 2.0 * pad - fixed).min(natural).max(90.0);
 
@@ -880,7 +1013,12 @@ impl Application for BevelPopup {
             y += knob_h + gap;
             self.cut_rect = Rect { x, y, width: w, height: cut_h };
             y += cut_h + gap;
-            if self.profile_dropdown.selected == 0 {
+            // Keyed off the SHAPE's curve, never the dropdown index — several
+            // shapes share the Wall curve, and this has to agree with the paint
+            // side's `wall_active` or the row is laid out for one set and drawn
+            // from the other, which parks every knob off-screen and looks like
+            // the sliders vanished.
+            if self.active_curve() == Curve::Wall {
                 knob_row(&mut self.wall, x, y);
                 park(&mut self.edge);
             } else {
@@ -933,10 +1071,7 @@ impl Application for BevelPopup {
             None,
         );
 
-        let shape = Shape::ALL
-            .get(self.profile_dropdown.selected)
-            .copied()
-            .unwrap_or(Shape::Recess);
+        let shape = self.active_shape();
         let wall_active = shape.curve() == Curve::Wall;
         let active = if wall_active { &self.wall } else { &self.edge };
         draw_section(&mut pc, self.cut_rect, active, shape);
diff --git a/src/scene/mod.rs b/src/scene/mod.rs
index 8d7daa0..6e40385 100644
--- a/src/scene/mod.rs
+++ b/src/scene/mod.rs
@@ -10,6 +10,7 @@ pub mod arena;
 pub mod layout;
 pub mod paint;
 pub mod painter;
+pub mod relief_shade;
 pub mod tree;
 
 pub use arena::{Arena, Node, NodeId};
diff --git a/src/scene/relief_shade.rs b/src/scene/relief_shade.rs
new file mode 100644
index 0000000..3f559f6
--- /dev/null
+++ b/src/scene/relief_shade.rs
@@ -0,0 +1,242 @@
+//! 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.
+//!
+//! 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
+//! a light azimuth. A section drawn that way shows the geometry honestly and
+//! the shading not at all — it cannot tell you that a wall reads hot, which is
+//! the single most common thing you go to the editor to judge.
+//!
+//! **Drift is the hazard**, since WGSL and Rust cannot share a function body.
+//! Two defences: the light vector and material live HERE and the renderer
+//! reads them from here (`window_runner`'s `plate_light`/`plate_mat`), and the
+//! constants below are checked against the shader's own source text by a unit
+//! test. Anything that is only a comment away from disagreeing is not shared.
+
+/// Ambient floor of the plate lighting model. Mirrors `PLATE_AMBIENT`.
+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.
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub enum CarveMode {
+    Recess,
+    Boss,
+    Ridge,
+    Trough,
+}
+
+/// The plastic material: `[shading strength, specular strength, shininess,
+/// curvature/AO strength]` as carried in `PlatePush::mat`.
+#[derive(Clone, Copy, Debug)]
+pub struct Material {
+    pub strength: f32,
+    pub spec: f32,
+    pub shininess: f32,
+    pub curvature: f32,
+}
+
+impl Material {
+    /// The DE's material, strength tracking `bevel_depth` against the default.
+    /// This is the ONE definition — the renderer's push constants come from
+    /// here too.
+    pub fn from_style() -> Self {
+        Self {
+            strength: crate::layout::bevel_depth() / 0.15,
+            spec: 0.4,
+            shininess: 24.0,
+            curvature: 0.2,
+        }
+    }
+
+    pub fn to_array(self) -> [f32; 4] {
+        [self.strength, self.spec, self.shininess, self.curvature]
+    }
+}
+
+/// The DE's light as a unit vector in screen space (+z out of the screen), at
+/// the fixed 45° elevation the renderer uses. The ONE definition, as with
+/// [`Material::from_style`].
+pub fn light_vector() -> [f32; 3] {
+    let az = crate::layout::light_source_position();
+    let el = std::f32::consts::FRAC_PI_4;
+    [az.cos() * el.cos(), -az.sin() * el.cos(), el.sin()]
+}
+
+/// Shading of the flat face — the denominator every carve is expressed
+/// relative to, so an untouched surface composites to exactly nothing.
+pub fn flat_shade(light: [f32; 3]) -> f32 {
+    PLATE_AMBIENT + (1.0 - PLATE_AMBIENT) * light[2]
+}
+
+/// The analytic carve slope: smoothstep normally, smootherstep under a
+/// continuous-curvature `corner_shape`. Mirrors `carve_slope`'s analytic
+/// branch; a custom profile replaces it with the LUT, which callers model by
+/// passing their own slope function to [`carve_shade`].
+pub fn analytic_carve_slope(v: f32) -> f32 {
+    if crate::layout::corner_shape() > 2.001 {
+        let w = v * (1.0 - v);
+        30.0 * w * w
+    } else {
+        6.0 * v * (1.0 - v)
+    }
+}
+
+/// Specular term of a tilted surface under the DE light. Mirrors `roll_spec`.
+pub fn roll_spec(sv: [f32; 2], light: [f32; 3], mat: &Material) -> f32 {
+    let m = (sv[0] * sv[0] + sv[1] * sv[1]).sqrt();
+    if m < 1e-5 {
+        return 0.0;
+    }
+    let hv = {
+        let h = [light[0], light[1], light[2] + 1.0];
+        let n = (h[0] * h[0] + h[1] * h[1] + h[2] * h[2]).sqrt().max(1e-6);
+        [h[0] / n, h[1] / n, h[2] / n]
+    };
+    let facing = [sv[0] / m, sv[1] / m];
+    let cos_t = 1.0 / (1.0 + m * m).sqrt();
+    let sin_t = m * cos_t;
+    let hxy = (hv[0] * hv[0] + hv[1] * hv[1]).sqrt();
+    let prof = cos_t * hv[2] + sin_t * hxy;
+    let az = ((facing[0] * hv[0] + facing[1] * hv[1]) / hxy.max(1e-4)).clamp(0.0, 1.0);
+    mat.spec * (prof.powf(mat.shininess) - hv[2].powf(mat.shininess)).max(0.0) * az * az
+}
+
+/// The signed shading value one carve contributes at `u` across its wall —
+/// the shader's `v`, before the tint branch. Positive is a white screen over
+/// what is beneath, negative a black multiply; magnitude is the alpha.
+///
+/// `facing` is the SDF gradient direction (unit, pointing OUT of the carve's
+/// box). `slope_at` is the profile's slope function — pass
+/// [`analytic_carve_slope`] for the default material, or the derivative of a
+/// custom height curve to model an installed LUT.
+///
+/// `att` (the host-box roll fade) is left to the caller: it depends on where
+/// the carve sits inside its host, not on the profile.
+pub fn carve_shade(
+    mode: CarveMode,
+    u: f32,
+    facing: [f32; 2],
+    slope_at: &dyn Fn(f32) -> f32,
+    light: [f32; 3],
+    mat: &Material,
+) -> f32 {
+    let u = u.clamp(0.0, 1.0);
+    let (slope, curv) = match mode {
+        // The straddling pair: ONE profile evaluation on the folded coordinate,
+        // amplitude halved so the wall tilt matches a step's.
+        CarveMode::Ridge | CarveMode::Trough => {
+            let w = (2.0 * u).min(2.0 - 2.0 * u).clamp(0.0, 1.0);
+            let up = if mode == CarveMode::Ridge { 1.0 } else { -1.0 };
+            let rising = if u <= 0.5 { 1.0 } else { -1.0 } * up;
+            (
+                rising * 0.5 * RECESS_DEPTH * 2.0 * slope_at(w),
+                -up * mat.curvature * (w * std::f32::consts::TAU).sin(),
+            )
+        }
+        _ => {
+            let dir = if mode == CarveMode::Boss { 1.0 } else { -1.0 };
+            (
+                dir * RECESS_DEPTH * slope_at(u),
+                -dir * mat.curvature * (u * std::f32::consts::TAU).sin(),
+            )
+        }
+    };
+    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;
+    let spec = roll_spec(sv, light, mat);
+    (diff / flat_shade(light) - 1.0 + curv + spec) * mat.strength
+}
+
+/// Composite one carve's shading over what is already there, the way the
+/// renderer's alpha blend does.
+///
+/// This asymmetry is load-bearing and is why a wall's bright side always
+/// out-measures its dark side: brightening screens toward WHITE, darkening
+/// multiplies toward BLACK, so on a mid-grey surface the same |v| moves the
+/// pixel about twice as far up as down.
+pub fn composite(base: [f32; 3], v: f32) -> [f32; 3] {
+    let a = v.abs().min(1.0);
+    let target = if v >= 0.0 { 1.0f32 } else { 0.0f32 };
+    [
+        base[0] * (1.0 - a) + target * a,
+        base[1] * (1.0 - a) + target * a,
+        base[2] * (1.0 - a) + target * a,
+    ]
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// The shader's own source, so the constants below are checked against the
+    /// thing they mirror rather than against a comment.
+    const WGSL: &str = include_str!("../vk/shader2d.wgsl");
+
+    fn wgsl_const(name: &str) -> f32 {
+        let needle = format!("const {name}: f32 = ");
+        let rest = WGSL
+            .split(&needle)
+            .nth(1)
+            .unwrap_or_else(|| panic!("{name} not found in shader2d.wgsl"));
+        let lit: String = rest.chars().take_while(|c| *c != ';').collect();
+        lit.trim().parse().expect("numeric literal")
+    }
+
+    #[test]
+    fn constants_match_the_shader() {
+        assert_eq!(wgsl_const("PLATE_AMBIENT"), PLATE_AMBIENT);
+        assert_eq!(wgsl_const("RECESS_DEPTH"), RECESS_DEPTH);
+    }
+
+    /// A flat surface must composite to nothing, or the cover quad tints
+    /// everything it covers — the property the whole "relative to the flat
+    /// face" formulation exists to guarantee.
+    #[test]
+    fn flat_ground_shades_to_zero() {
+        let light = light_vector();
+        let mat = Material::from_style();
+        for mode in [CarveMode::Recess, CarveMode::Boss, CarveMode::Ridge, CarveMode::Trough] {
+            for u in [0.0f32, 1.0] {
+                let v = carve_shade(mode, u, [-1.0, 0.0], &analytic_carve_slope, light, &mat);
+                assert!(v.abs() < 1e-4, "{mode:?} at u={u} shaded {v}, expected 0");
+            }
+        }
+    }
+
+    /// Ridge and trough are the same wall with the height sign flipped, so
+    /// their shading is opposite WHERE THE WALL IS STEEP.
+    ///
+    /// Not everywhere, which is worth stating because it is the first thing you
+    /// would assume: the response has an EVEN component. Tilting a surface
+    /// either way shortens the face-on `n.z` term and adds a non-negative
+    /// specular, so near the plateau lips — where the directional part is
+    /// nearly nothing — a ridge and a trough both darken slightly. That is the
+    /// faint lip lobe visible on both, not an asymmetry bug.
+    #[test]
+    fn ridge_and_trough_oppose_where_the_wall_is_steep() {
+        let light = light_vector();
+        let mat = Material::from_style();
+        let sample = |m: CarveMode, u: f32| {
+            carve_shade(m, u, [-1.0, 0.0], &analytic_carve_slope, light, &mat)
+        };
+        // Steepest point of the folded profile: w = 1 at u = 0.5 is the crest
+        // (zero slope), so the extremes sit either side of it.
+        let steep = (0..=100)
+            .map(|i| i as f32 / 100.0)
+            .max_by(|a, b| sample(CarveMode::Ridge, *a).abs().total_cmp(&sample(CarveMode::Ridge, *b).abs()))
+            .unwrap();
+        let (r, t) = (sample(CarveMode::Ridge, steep), sample(CarveMode::Trough, steep));
+        assert!(r.abs() > 0.05, "ridge shading {r} at u={steep} is too faint to test");
+        assert!(r * t < 0.0, "ridge {r} and trough {t} agree in sign at u={steep}");
+    }
+}