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

commit4a73717f13af59e9e12bf3a40f171e1ba6bc662d
parent3cec8b0505
authorLucas Galante <[email protected]>
date2026-09-09 15:14
feat(relief): a real depth axis — pinned heights, and light split from depth

Step 3 of the unit system. Until now relief had one true length (the
wall's run, bevel_width), a lighting strength misnamed depth, and a
geometry that was a hard-coded ratio of the run (RECESS_DEPTH × width
for carves, a quarter-round of radius width for the plate roll). Nothing
could be dimensioned honestly.

- `style.surface.relief.height` (a carve's drop) and `edge_height` (the
  roll's rise) are lengths — `height=(mm)0.3` resolves through the
  display metric. Unset, the analytic ratios apply, so every existing
  config looks exactly as before. `depth` keeps meaning the light
  strength; `light` is its honest alias.
- `layout::carve_depth_px` states the drop rule ONCE for the
  tessellator's CSG features and, via a new `WindowInfo.relief_meta`
  (pinned heights in physical px, re-uploaded when they change), the
  shader's free carves; `roll_slope` scales by the pinned rise. The
  shading twin carries the same ratios on `Material` (carve_depth,
  roll_height) so cce-relief's strip predicts what the shader draws.
- `ReliefSpec` gains `h=` (a length: `h=0.5mm`, or bare px); `d=` stays
  the light strength on the wire, `l=` reads as an alias, and the field
  is now named `light`.
- cce-relief: Depth is relabelled Light; a Height knob (0 = follow the
  width) edits the pinned drop; the section draws the drop at its real
  ratio and labels the depth axis in millimetres whenever the metric is
  real (px when assumed); Save writes `height` as a `(mm)` length then,
  px otherwise; the seeds are installed into the registry at open so a
  `--config` file's material is what the preview shows from frame one.

Verified in a scale-2 shadow with a configured metric: default opens at
the analytic 0.6 drop with the axis in mm; `height=(mm)8` seeds the knob
to 14.1 px, draws the 7.9 mm drop over the 5.3 mm run, and hardens the
strip and the popup's own wells. 334 lib tests pass, including a new one
that a deeper carve shades harder and its plateaus still shade to zero.

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

 CLAUDE.md                    |  16 +++++
 src/backend/window_runner.rs |  15 ++--
 src/bin/cce-relief.rs        | 163 ++++++++++++++++++++++++++++++++++++++-----
 src/layout.rs                |  59 +++++++++++++++-
 src/relief_spec.rs           |  48 ++++++++++---
 src/scene/relief_shade.rs    |  31 ++++++--
 src/vk/renderer.rs           |  25 ++++++-
 src/vk/shader2d.wgsl         |  22 +++++-
 8 files changed, 332 insertions(+), 47 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 0415335..9146c37 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -296,6 +296,22 @@ adds the bridge to real lengths, in two parts:
   beside `scale::set_scale_factor` (`units::set_metric`); apps read
   `units::metric()`, `units::mm(v)`, or `Len::to_px()`.
 
+**Relief has a real depth axis now.** `style.surface.relief.width` (the wall's
+run) and the new **`height`** (a carve's drop) and **`edge_height`** (the plate
+roll's rise) are all lengths — `height=(mm)0.3` is honest geometry, resolved
+through the metric. Unset, a carve drops `relief_shade::RECESS_DEPTH` (0.6) of
+its wall (saturating at the DE roll width) and the roll is a quarter-round of
+radius width — the look every config had. `style.surface.relief.depth` is NOT a
+length: it is the light strength (`bevel_depth` → `Material.strength`), and
+**`light`** is its honest alias. `layout::carve_depth_px` states the drop rule
+once for the tessellator's CSG features and, through `WindowInfo.relief_meta`,
+the shader's free carves; `carve_depth_ratio` / `roll_height_ratio` feed the
+shading twin (`Material.carve_depth` / `roll_height`). A `(relief)` value
+carries the drop as `h=` (a length: `h=0.5mm`, or bare px) beside `w=` and
+`d=` (light; `l=` reads as an alias). `cce-relief`'s Height knob is the editor:
+its section's depth numbers read in mm when the metric is real, and Save
+writes `height` as a `(mm)` length then, px otherwise.
+
 Why not millimetres inside: UI sizes are perceptual and angular, not physical
 — a hit target should not become 8 mm on a projector three metres away.
 Documents and fabrication content live in real units and convert at view
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 4d97110..eb52367 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -2115,12 +2115,11 @@ pub fn tessellate_display_list(
                         if !edges.2 { y1 += ext; }
                         if !edges.3 { x0 -= ext; }
                         let t_px = *depth * scale;
-                        // Depth saturates at the DE's nominal roll width: a wall
-                        // wider than the plate's own perimeter roll spreads that
-                        // same step over the longer run — a softer transition —
-                        // instead of cutting proportionally deeper (which would
-                        // keep the wall just as steep no matter how wide it got).
-                        let k_mag = RECESS_DEPTH_RATIO * t_px.min(crate::layout::bevel_width() * scale);
+                        // The carve's drop: the material's pinned height, else
+                        // the analytic ratio of the wall saturating at the DE's
+                        // roll width (`layout::carve_depth_px` states the rule
+                        // once for this path and the shader's free carves).
+                        let k_mag = crate::layout::carve_depth_px(*depth) * scale;
                         // Negative depth = raised (Boss); the shader's summed
                         // slope vectors and curvature sign follow it.
                         let k_px = if raised { -k_mag } else { k_mag };
@@ -2611,10 +2610,6 @@ pub fn tessellate_display_list(
     (verts, batches, images, features)
 }
 
-/// A carve's depth as a fraction of its transition width — must match the
-/// shader's `RECESS_DEPTH` (used by the mode-2 overlay fallback).
-const RECESS_DEPTH_RATIO: f32 = 0.6;
-
 /// The push-constant block for a raised SDF-lit plate over `rect` (logical px in,
 /// physical px out). Corner radii clamp to the half-extent cap the SDF needs.
 ///
diff --git a/src/bin/cce-relief.rs b/src/bin/cce-relief.rs
index 239b0d6..3f0a577 100644
--- a/src/bin/cce-relief.rs
+++ b/src/bin/cce-relief.rs
@@ -18,6 +18,15 @@
 //! there, which is how a stack whose geometry looks reasonable can still read
 //! hot.
 //!
+//! The knobs under the section: the wall curve's Shoulder / Base / Bias,
+//! then **Light** (the `bevel_depth` slot — how hard the light falls across
+//! the wall; not a length), **Width** (the wall's run, logical px) and
+//! **Height** (the wall's drop, logical px; 0 = follow the width at the
+//! analytic ratio). Height is the fabrication axis: the section's depth
+//! numbers read in millimetres whenever the display metric is real
+//! (`cce_ui::units`), and Save writes `style.surface.relief.height` as a
+//! `(mm)` length then, px otherwise.
+//!
 //! 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`
@@ -56,11 +65,14 @@ use wayland_client::QueueHandle;
 const HEADER_FONT_SIZE: f32 = 13.0;
 const HEADER_COLOR: [u8; 3] = [0x9a, 0x9a, 0xa4];
 
-/// Knob ranges: depth (how hard the light falls across the wall) and width
-/// (how far the wall runs, logical px). Defaults per `layout::bevel_depth` /
-/// `bevel_width`.
+/// Knob ranges: light (how hard the light falls across the wall — the
+/// `bevel_depth` slot, NOT a length), width (how far the wall runs, logical
+/// px) and height (how far it drops, logical px; 0 = follow the width at
+/// the analytic ratio, the pre-height look). Defaults per
+/// `layout::bevel_depth` / `bevel_width` / `bevel_height`.
 const DEPTH_RANGE: (f32, f32) = (0.0, 0.6);
 const WIDTH_RANGE: (f32, f32) = (2.0, 24.0);
+const HEIGHT_RANGE: (f32, f32) = (0.0, 24.0);
 
 /// Sample count for the spec written to config — enough that the 32-slot
 /// renderer LUT sees the curve, few enough that the config line stays sane.
@@ -102,7 +114,7 @@ fn content_height(width: f32) -> f32 {
     let button_h = 26.0;
     let status_h = HEADER_FONT_SIZE + 4.0;
     let fixed = knob_h + gap
-        + 5.0 * (knob_h + gap)
+        + 6.0 * (knob_h + gap)
         + button_h + 8.0 + status_h + 2.0 * gap;
     let natural = (w - 2.0 * CUT_MARGIN - GUTTER_L - 2.0 * MIN_BAND)
         + 2.0 * CUT_MARGIN
@@ -442,6 +454,7 @@ struct BevelPopup {
     edge: ProfileKnobs,
     depth_slider: Adapted<Slider>,
     width_slider: Adapted<Slider>,
+    height_slider: Adapted<Slider>,
     save_button: Adapted<Button>,
     /// Cancel = discard-and-close: edits are live only in THIS process, so
     /// with nothing persisted, closing IS the discard (same as Escape).
@@ -516,14 +529,24 @@ fn draw_section(pc: &mut PaintCtx, rect: Rect, profile: &ProfileKnobs, shape: Sh
     let avail_h = sec_h - 2.0 * m - UNDERSIDE - GUTTER_B;
 
     let run = shape.run();
+    // Drop over run: the material's pinned height against the DE roll width
+    // (`layout::carve_depth_ratio`), the roll's own rise for the edge roll.
+    // Everything below scales the shape's unit drop by it, so the section
+    // shows the geometry the shader shades — a 0.5 mm drop over a 2 mm wall
+    // draws at that slope, not at the analytic 0.6.
+    let ratio = if matches!(shape, Shape::EdgeRoll) {
+        cce_ui::layout::roll_height_ratio()
+    } else {
+        cce_ui::layout::carve_depth_ratio()
+    };
     // Vertical extent of THIS shape, sampled — a ridge lives above the surface,
     // a recess below, a trough only half a drop down.
     let (mut h_lo, mut h_hi) = (0.0f32, 0.0f32);
     for i in 0..=64 {
         let t = run * i as f32 / 64.0;
         if let Some(hv) = shape.height(profile, t) {
-            h_lo = h_lo.min(hv);
-            h_hi = h_hi.max(hv);
+            h_lo = h_lo.min(hv * ratio);
+            h_hi = h_hi.max(hv * ratio);
         }
     }
     let h_span = (h_hi - h_lo).max(0.25);
@@ -552,12 +575,22 @@ fn draw_section(pc: &mut PaintCtx, rect: Rect, profile: &ProfileKnobs, shape: Sh
     // drops — 0 at the surrounding surface, positive down into the material.
     let grid = [0.25f32, 0.25, 0.28, 0.6];
     let num_color = [0x84u8, 0x84, 0x92];
+    // The depth numbers are REAL lengths: one wall width of drop is
+    // `bevel_width` logical px, shown in millimetres when the display metric
+    // is measured or configured, in px when it is only assumed.
+    let metric = cce_ui::units::metric();
+    let wall_px = cce_ui::layout::bevel_width();
+    let (axis_unit, per_wall) = if metric.is_real() {
+        ("mm", wall_px * metric.mm_per_px())
+    } else {
+        ("px", wall_px)
+    };
     let mut gh = (h_lo / 0.25).round() * 0.25;
     while gh <= h_hi + 1e-3 {
         let gy = y_zero + gh * unit;
         pc.quad(Rect { x: x0, y: gy, width: wall_w, height: 1.0 }, grid);
         pc.text_with(
-            format!("{gh:.2}"),
+            format!("{:.2}", gh * per_wall),
             rect.x + m + 2.0,
             gy - 5.0,
             10.0,
@@ -593,9 +626,9 @@ fn draw_section(pc: &mut PaintCtx, rect: Rect, profile: &ProfileKnobs, shape: Sh
         } else if inside {
             shape
                 .height(profile, t_of(x))
-                .map(|hv| y_zero + hv * unit)
+                .map(|hv| y_zero + hv * ratio * unit)
         } else if has_floor {
-            shape.height(profile, run).map(|hv| y_zero + hv * unit)
+            shape.height(profile, run).map(|hv| y_zero + hv * ratio * unit)
         } else {
             None // past the silhouette: air
         }
@@ -650,6 +683,17 @@ fn draw_section(pc: &mut PaintCtx, rect: Rect, profile: &ProfileKnobs, shape: Sh
         );
         rt += 0.25;
     }
+    // The depth axis's unit, in the left gutter on the run-number row —
+    // the one spot no excursion of the section can reach.
+    pc.text_with(
+        axis_unit.to_string(),
+        rect.x + m + 2.0,
+        slab_bot + 3.0,
+        10.0,
+        num_color,
+        Some("monospace".to_string()),
+        None,
+    );
 
     // THE SHADING STRIP: what the shader will actually put on screen along
     // this section, as opposed to the geometry drawn above it.
@@ -923,6 +967,17 @@ fn draw_section(pc: &mut PaintCtx, rect: Rect, profile: &ProfileKnobs, shape: Sh
 }
 
 impl BevelPopup {
+    /// The pinned drop as the length it should be written as: millimetres
+    /// when the display metric is real, logical px when it is only assumed.
+    fn height_len(&self, px: f32) -> cce_ui::units::Len {
+        let m = cce_ui::units::metric();
+        if m.is_real() {
+            cce_ui::units::Len::mm(((px * m.mm_per_px()) * 1000.0).round() / 1000.0)
+        } else {
+            cce_ui::units::Len::px(px)
+        }
+    }
+
     /// 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 {
@@ -942,7 +997,7 @@ impl BevelPopup {
         self.active_shape().curve()
     }
 
-    fn root_ids(&self) -> [WidgetId; 12] {
+    fn root_ids(&self) -> [WidgetId; 13] {
         [
             self.profile_dropdown.id(),
             self.edge_dropdown.id(),
@@ -954,12 +1009,13 @@ impl BevelPopup {
             self.edge.bias.id(),
             self.depth_slider.id(),
             self.width_slider.id(),
+            self.height_slider.id(),
             self.save_button.id(),
             self.cancel_button.id(),
         ]
     }
 
-    fn roots(&mut self) -> [*mut (dyn WidgetHost + 'static); 12] {
+    fn roots(&mut self) -> [*mut (dyn WidgetHost + 'static); 13] {
         [
             self.profile_dropdown.as_ptr_mut(),
             self.edge_dropdown.as_ptr_mut(),
@@ -971,6 +1027,7 @@ impl BevelPopup {
             self.edge.bias.as_ptr_mut(),
             self.depth_slider.as_ptr_mut(),
             self.width_slider.as_ptr_mut(),
+            self.height_slider.as_ptr_mut(),
             self.save_button.as_ptr_mut(),
             self.cancel_button.as_ptr_mut(),
         ]
@@ -1021,6 +1078,16 @@ impl BevelPopup {
             println!("width {v:.2}");
             self.needs_rebuild = true;
         }
+        if self.height_slider.take_change() {
+            let v = self.height_slider.inner().get_scaled_value();
+            // 0 = follow the width (`layout::bevel_height` reads 0 as unset).
+            if let Ok(mut reg) = cce_ui::layout::get_style_registry().write() {
+                reg.set_float("bevel_height", v);
+            }
+            let m = cce_ui::units::metric();
+            println!("height {v:.2}px = {:.3}mm ({})", v * m.mm_per_px(), m.source.as_str());
+            self.needs_rebuild = true;
+        }
         if self.save_button.take_click() {
             self.save_to_config();
             self.needs_rebuild = true;
@@ -1042,9 +1109,11 @@ impl BevelPopup {
         // section is not part of a feature material; an untouched analytic
         // wall writes no profile at all.
         if let Some(key) = self.target_key.clone() {
+            let h = self.height_slider.inner().get_scaled_value();
             let spec = cce_ui::relief_spec::ReliefSpec {
                 width: self.width_slider.inner().get_scaled_value(),
-                depth: Some(self.depth_slider.inner().get_scaled_value()),
+                height: (h > 0.0).then(|| self.height_len(h)),
+                light: Some(self.depth_slider.inner().get_scaled_value()),
                 knobs: Some(self.wall.values()),
                 profile: self.wall.custom.then(|| self.wall.last_spec.clone()),
             };
@@ -1077,7 +1146,24 @@ impl BevelPopup {
         let wb = |key: &str, value: &str| {
             cce_ui::config::write_config_value_typed(&p, key, value, "style", Some("bevel"))
         };
-        let ok = w("style.surface.relief.depth", &depth)
+        // The pinned drop is a LENGTH: written in millimetres when the
+        // display metric is real (fabrication reads it straight), in logical
+        // px when it is only assumed; 0 = follow the width.
+        let h = self.height_slider.inner().get_scaled_value();
+        let height_ok = if h > 0.0 {
+            let len = self.height_len(h);
+            cce_ui::config::write_config_value_typed(
+                &p,
+                "style.surface.relief.height",
+                &cce_ui::units::fmt_num(len.value),
+                "style",
+                Some(len.unit.suffix()),
+            )
+        } else {
+            w("style.surface.relief.height", "0")
+        };
+        let ok = height_ok
+            & w("style.surface.relief.depth", &depth)
             & w("style.surface.relief.width", &width)
             & w("style.surface.relief.profile", &self.wall.last_spec)
             & w("style.surface.relief.edge_profile", &self.edge.last_spec)
@@ -1147,6 +1233,15 @@ impl Application for BevelPopup {
         let rel_f32 = |k: &str| {
             target_relief.as_ref().and_then(|r| r.get(k)).and_then(|v| v.as_f64()).map(|f| f as f32)
         };
+        // A length key: a bare number is logical px, a `(mm)`-annotated one
+        // arrives as the string "0.3mm" and resolves through the metric.
+        let rel_len = |k: &str| {
+            target_relief.as_ref().and_then(|r| r.get(k)).and_then(|v| {
+                v.as_f64()
+                    .map(|f| f as f32)
+                    .or_else(|| v.as_str().and_then(cce_ui::units::Len::parse).map(|l| l.to_px()))
+            })
+        };
         // `--key` seeds: the single `(relief)` value at that key wins over
         // both the target file's material keys and the registry. Installing
         // it live BEFORE the knob structs are built means the preview shows
@@ -1163,9 +1258,12 @@ impl Application for BevelPopup {
         if let Some(ks) = &key_spec {
             if let Ok(mut reg) = cce_ui::layout::get_style_registry().write() {
                 reg.set_float("bevel_width", ks.width);
-                if let Some(d) = ks.depth {
+                if let Some(d) = ks.light {
                     reg.set_float("bevel_depth", d);
                 }
+                if let Some(h) = ks.height {
+                    reg.set_len("bevel_height", h);
+                }
             }
             cce_ui::layout::install_wall_profile_spec(ks.profile.as_deref());
         }
@@ -1186,14 +1284,32 @@ impl Application for BevelPopup {
 
         let depth = key_spec
             .as_ref()
-            .and_then(|s| s.depth)
+            .and_then(|s| s.light)
+            .or_else(|| rel_f32("light"))
             .or_else(|| rel_f32("depth"))
             .unwrap_or_else(cce_ui::layout::bevel_depth);
+        let height = key_spec
+            .as_ref()
+            .and_then(|s| s.height)
+            .map(|l| l.to_px())
+            .or_else(|| rel_len("height"))
+            .or_else(cce_ui::layout::bevel_height)
+            .unwrap_or(0.0);
         let width = key_spec
             .as_ref()
             .map(|s| s.width)
             .or_else(|| rel_f32("width"))
             .unwrap_or_else(cce_ui::layout::bevel_width);
+        // The seeds ARE the material this window previews: install them so
+        // the section, the strip and the popup's own plate show the target
+        // file's width / light / height from the first frame, not the
+        // DE-wide registry's until a knob moves. (A `--key` spec was
+        // installed above already; this repeats it harmlessly.)
+        if let Ok(mut reg) = cce_ui::layout::get_style_registry().write() {
+            reg.set_float("bevel_depth", depth);
+            reg.set_float("bevel_width", width);
+            reg.set_float("bevel_height", height);
+        }
         // A key target labels the window by the key, not the file.
         let target_label = match &target_key {
             Some(k) => {
@@ -1204,6 +1320,7 @@ impl Application for BevelPopup {
         };
         let (dmin, dmax) = DEPTH_RANGE;
         let (wmin, wmax) = WIDTH_RANGE;
+        let (hmin, hmax) = HEIGHT_RANGE;
         // The persisted per-app plate opacity, falling back to the DE look.
         // New path first, then the pre-rename one, so an existing opacity
         // setting keeps working without a migration step.
@@ -1232,7 +1349,7 @@ impl Application for BevelPopup {
             wall: ProfileKnobs::new(wall_seed, cce_ui::layout::bevel_profile_slopes().is_some()),
             edge: ProfileKnobs::new(edge_seed, cce_ui::layout::roll_profile_slopes().is_some()),
             depth_slider: Slider::new()
-                .with_label("Depth")
+                .with_label("Light")
                 .with_range(dmin, dmax)
                 .with_value(((depth - dmin) / (dmax - dmin)).clamp(0.0, 1.0))
                 .with_readout(true)
@@ -1245,6 +1362,13 @@ impl Application for BevelPopup {
                 .with_readout(true)
                 .with_decimals(1)
                 .with_scroll(true),
+            height_slider: Slider::new()
+                .with_label("Height")
+                .with_range(hmin, hmax)
+                .with_value(((height - hmin) / (hmax - hmin)).clamp(0.0, 1.0))
+                .with_readout(true)
+                .with_decimals(1)
+                .with_scroll(true),
             save_button: Button::new(0.0, 0.0, 0.0, 0.0).with_label("Save"),
             cancel_button: Button::new(0.0, 0.0, 0.0, 0.0).with_label("Cancel"),
             exit_requested: false,
@@ -1342,13 +1466,13 @@ impl Application for BevelPopup {
             let knob_h = 22.0 + strip;
             let button_h = 26.0;
             let status_h = HEADER_FONT_SIZE + 4.0;
-            // Rows above/below the cutaway: the selector row, then FIVE
+            // Rows above/below the cutaway: the selector row, then SIX
             // stacked sliders, then buttons and status. Stacked rather than
             // gridded because a slider's label and readout want the full width
             // — three to a row truncated both, and the two-wide Depth/Width row
             // set a different rhythm again for no reason.
             let fixed = knob_h + gap                      // selector row
-                + 5.0 * (knob_h + gap)                    // Shoulder..Width
+                + 6.0 * (knob_h + gap)                    // Shoulder..Height
                 + button_h + 8.0 + status_h + 2.0 * gap;  // buttons + status
             // The cutaway absorbs spare height — but only up to its NATURAL
             // height for this width (the proportional square domain plus
@@ -1401,6 +1525,8 @@ impl Application for BevelPopup {
             y += knob_h + gap;
             self.width_slider.set_rect(x, y, w, knob_h);
             y += knob_h + gap;
+            self.height_slider.set_rect(x, y, w, knob_h);
+            y += knob_h + gap;
             self.save_button.set_rect(x, y, 96.0, button_h);
             self.cancel_button.set_rect(x + 96.0 + 12.0, y, 96.0, button_h);
             y += button_h + 8.0;
@@ -1463,6 +1589,7 @@ impl Application for BevelPopup {
             &knobs.bias,
             &self.depth_slider,
             &self.width_slider,
+            &self.height_slider,
         ] {
             cce_ui::scene::painter::paint_root_into(&self.ui_context, s, &mut pc);
         }
diff --git a/src/layout.rs b/src/layout.rs
index d5d5d64..b1a8d38 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -146,8 +146,14 @@ fn flatten_json_to_flat_props(val: &serde_json::Value, prefix: &str, flat_props:
                 // (these shade every bevel/boss/recess in the toolkit — the
                 // compositor never read them, so the old window_manager
                 // spelling survives only as a compat alias).
-                "style.surface.relief.depth" | "window_manager.bevel_depth" => "bevel_depth",
+                // `depth` is the light strength, not a length — `light` is
+                // the honest spelling, `depth` the one every config has.
+                "style.surface.relief.depth" | "style.surface.relief.light" | "window_manager.bevel_depth" => "bevel_depth",
                 "style.surface.relief.width" | "window_manager.bevel_width" => "bevel_width",
+                // The geometric heights, both lengths (unit-aware): a carve's
+                // drop and the plate roll's rise. Unset = follow the width.
+                "style.surface.relief.height" => "bevel_height",
+                "style.surface.relief.edge_height" => "roll_height",
                 // Ramp-spec strings for the custom wall/roll profiles
                 // (written by cce-relief, installed by reload_config).
                 "style.surface.relief.profile" => "bevel_profile_spec",
@@ -1717,6 +1723,57 @@ pub fn bevel_width() -> f32 {
     get_style_registry().read().unwrap().get_float("bevel_width").unwrap_or(9.3)
 }
 
+/// A carve's geometric drop when the material pins one
+/// (`style.surface.relief.height`, a length — `(mm)0.3` resolves through
+/// the display metric), in logical px. `None` = follow the wall width at the
+/// analytic ratio ([`crate::scene::relief_shade::RECESS_DEPTH`]), the look
+/// every config had before heights existed. A configured 0 reads as unset,
+/// which is how an editor puts a material back on "follow".
+pub fn bevel_height() -> Option<f32> {
+    lazy_init_style_registry();
+    get_style_registry()
+        .read()
+        .unwrap()
+        .get_float("bevel_height")
+        .filter(|h| h.is_finite() && *h > 0.0)
+}
+
+/// The plate roll's rise when pinned (`style.surface.relief.edge_height`, a
+/// length), logical px. `None` = a quarter-round of radius `bevel_width`.
+pub fn roll_height() -> Option<f32> {
+    lazy_init_style_registry();
+    get_style_registry()
+        .read()
+        .unwrap()
+        .get_float("roll_height")
+        .filter(|h| h.is_finite() && *h > 0.0)
+}
+
+/// The drop of a carve whose wall runs `wall` logical px: the pinned height
+/// when there is one, else the analytic ratio of the wall — saturating at the
+/// DE's roll width, so a wall wider than the plate's own perimeter roll
+/// spreads the same step over a longer run (a softer transition) instead of
+/// cutting proportionally deeper. The tessellator's CSG features and the
+/// shader's free carves both derive from this rule.
+pub fn carve_depth_px(wall: f32) -> f32 {
+    match bevel_height() {
+        Some(h) => h,
+        None => crate::scene::relief_shade::RECESS_DEPTH * wall.min(bevel_width()),
+    }
+}
+
+/// Drop over run for a wall of the DE roll width — what the shading twin
+/// scales its slopes by.
+pub fn carve_depth_ratio() -> f32 {
+    let w = bevel_width().max(0.001);
+    carve_depth_px(w) / w
+}
+
+/// Rise over run of the plate roll: 1 (the quarter-round) unless pinned.
+pub fn roll_height_ratio() -> f32 {
+    roll_height().map_or(1.0, |h| h / bevel_width().max(0.001))
+}
+
 /// Sample count of the custom bevel profile LUT ([`set_bevel_profile_keys`]).
 pub const BEVEL_PROFILE_SAMPLES: usize = 32;
 
diff --git a/src/relief_spec.rs b/src/relief_spec.rs
index 68a980f..8afe3b4 100644
--- a/src/relief_spec.rs
+++ b/src/relief_spec.rs
@@ -9,7 +9,12 @@
 //! ```
 //!
 //! - `w` — wall/roll width in logical px (required)
-//! - `d` — light depth across the wall (`bevel_depth`; optional)
+//! - `h` — the wall's geometric drop, a length: `h=0.5mm`, `h=4px`, or a
+//!   bare number of logical px (optional; absent = follow the width at the
+//!   analytic ratio, `relief_shade::RECESS_DEPTH` × width)
+//! - `d` — light strength across the wall (`bevel_depth`; optional). Kept
+//!   as `d` on the wire for every reader already out there; `l` is read as
+//!   an alias. It is NOT a length — `h` is.
 //! - `k` — the editor's Shoulder/Base/Bias knob triple, a ride-along seed
 //!   so `cce-relief` reopens where it was left (optional)
 //! - `p` — the wall's height curve as the same ramp spec
@@ -25,8 +30,11 @@
 pub struct ReliefSpec {
     /// Wall/roll width in logical px.
     pub width: f32,
-    /// Light depth (`bevel_depth`); `None` = keep the process's material.
-    pub depth: Option<f32>,
+    /// The wall's drop, a length; `None` = follow the width at the analytic
+    /// ratio.
+    pub height: Option<crate::units::Len>,
+    /// Light strength (`bevel_depth`); `None` = keep the process's material.
+    pub light: Option<f32>,
     /// Shoulder/Base/Bias editor knobs behind `profile` — seed only, the
     /// renderer never reads them.
     pub knobs: Option<(f32, f32, f32)>,
@@ -41,14 +49,20 @@ impl ReliefSpec {
     /// a spec without a width says nothing drawable.
     pub fn parse(s: &str) -> Option<Self> {
         let mut width = None;
-        let mut depth = None;
+        let mut height = None;
+        let mut light = None;
         let mut knobs = None;
         let mut profile = None;
         for tok in s.split_whitespace() {
             let Some((k, v)) = tok.split_once('=') else { continue };
             match k {
                 "w" => width = v.parse::<f32>().ok().filter(|w| w.is_finite() && *w >= 0.0),
-                "d" => depth = v.parse::<f32>().ok().filter(|d| d.is_finite()),
+                "d" | "l" => light = v.parse::<f32>().ok().filter(|d| d.is_finite()),
+                "h" => {
+                    height = crate::units::Len::parse(v)
+                        .or_else(|| v.parse::<f32>().ok().map(crate::units::Len::px))
+                        .filter(|l| l.value.is_finite() && l.value > 0.0);
+                }
                 "k" => {
                     let mut it = v.splitn(3, ',').map(|p| p.parse::<f32>().ok());
                     if let (Some(Some(a)), Some(Some(b)), Some(Some(c))) =
@@ -61,13 +75,16 @@ impl ReliefSpec {
                 _ => {}
             }
         }
-        Some(Self { width: width?, depth, knobs, profile })
+        Some(Self { width: width?, height, light, knobs, profile })
     }
 
     /// The string `parse` reads back — what `cce-relief --key` saves.
     pub fn serialize(&self) -> String {
         let mut out = format!("w={:.2}", self.width);
-        if let Some(d) = self.depth {
+        if let Some(h) = self.height {
+            out.push_str(&format!(" h={}", h.serialize()));
+        }
+        if let Some(d) = self.light {
             out.push_str(&format!(" d={d:.3}"));
         }
         if let Some((a, b, c)) = self.knobs {
@@ -88,7 +105,8 @@ mod tests {
     fn roundtrip_full() {
         let spec = ReliefSpec {
             width: 6.0,
-            depth: Some(0.35),
+            height: Some(crate::units::Len::mm(0.5)),
+            light: Some(0.35),
             knobs: Some((0.8, 0.2, 0.5)),
             profile: Some("0.000:0.000,0.500:0.700,1.000:1.000".to_string()),
         };
@@ -99,11 +117,23 @@ mod tests {
     fn width_only_and_unknown_pairs_skip() {
         let spec = ReliefSpec::parse("w=4 future=stuff junk").unwrap();
         assert_eq!(spec.width, 4.0);
-        assert_eq!(spec.depth, None);
+        assert_eq!(spec.height, None);
+        assert_eq!(spec.light, None);
         assert_eq!(spec.knobs, None);
         assert_eq!(spec.profile, None);
     }
 
+    #[test]
+    fn height_and_light_aliases() {
+        let s = ReliefSpec::parse("w=6 h=3 l=0.2").unwrap();
+        assert_eq!(s.height, Some(crate::units::Len::px(3.0)), "bare h is logical px");
+        assert_eq!(s.light, Some(0.2), "l is read as d");
+        let s = ReliefSpec::parse("w=6 h=0.5mm d=0.1").unwrap();
+        assert_eq!(s.height, Some(crate::units::Len::mm(0.5)));
+        assert_eq!(s.serialize(), "w=6.00 h=0.5mm d=0.100");
+        assert_eq!(ReliefSpec::parse("w=6 h=-1").unwrap().height, None);
+    }
+
     #[test]
     fn missing_or_bad_width_rejects() {
         assert_eq!(ReliefSpec::parse("d=0.3"), None);
diff --git a/src/scene/relief_shade.rs b/src/scene/relief_shade.rs
index 501a7ae..93a3b20 100644
--- a/src/scene/relief_shade.rs
+++ b/src/scene/relief_shade.rs
@@ -19,7 +19,8 @@
 
 /// 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`.
+/// A carve's drop as a fraction of its wall width when the material pins no
+/// height (`layout::bevel_height`). Mirrors `RECESS_DEPTH`.
 pub const RECESS_DEPTH: f32 = 0.6;
 
 /// Amplitude of the bright crest hugging a raised plate's silhouette.
@@ -47,6 +48,13 @@ pub struct Material {
     pub spec: f32,
     pub shininess: f32,
     pub curvature: f32,
+    /// A carve's drop over its run (`layout::carve_depth_ratio`): the
+    /// geometry the slopes are scaled by. [`RECESS_DEPTH`] unless a height is
+    /// pinned. Not in `to_array` — the shader reads it from `WindowInfo`.
+    pub carve_depth: f32,
+    /// The plate roll's rise over its run (`layout::roll_height_ratio`):
+    /// 1 for the quarter-round.
+    pub roll_height: f32,
 }
 
 impl Material {
@@ -59,6 +67,8 @@ impl Material {
             spec: 0.4,
             shininess: 24.0,
             curvature: 0.2,
+            carve_depth: crate::layout::carve_depth_ratio(),
+            roll_height: crate::layout::roll_height_ratio(),
         }
     }
 
@@ -143,14 +153,14 @@ pub fn carve_shade(
             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),
+                rising * 0.5 * mat.carve_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.carve_depth * slope_at(u),
                 -dir * mat.curvature * (u * std::f32::consts::TAU).sin(),
             )
         }
@@ -202,7 +212,7 @@ pub fn plate_surface(
     if !(0.0..=1.0).contains(&f) {
         return None;
     }
-    let slope = roll_slope_at(f);
+    let slope = roll_slope_at(f) * mat.roll_height;
     let sv = [facing[0] * slope, facing[1] * slope];
     let n = {
         let len = (sv[0] * sv[0] + sv[1] * sv[1] + 1.0).sqrt();
@@ -292,6 +302,19 @@ mod tests {
         }
     }
 
+    /// A pinned height is geometry: a deeper carve tilts its wall more and
+    /// shades harder, with nothing else changed.
+    #[test]
+    fn deeper_carve_shades_harder() {
+        let light = light_vector();
+        let shallow = Material { carve_depth: 0.3, ..Material::from_style() };
+        let deep = Material { carve_depth: 1.2, ..Material::from_style() };
+        let at = |m: &Material| carve_shade(CarveMode::Recess, 0.5, [-1.0, 0.0], &analytic_carve_slope, light, m).abs();
+        assert!(at(&deep) > at(&shallow) * 1.5, "deep {} vs shallow {}", at(&deep), at(&shallow));
+        // And the flat plateaus still composite to nothing.
+        assert!(carve_shade(CarveMode::Recess, 0.0, [-1.0, 0.0], &analytic_carve_slope, light, &deep).abs() < 1e-4);
+    }
+
     /// Ridge and trough are the same wall with the height sign flipped, so
     /// their shading is opposite WHERE THE WALL IS STEEP.
     ///
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index 8a61db2..2ee656b 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -131,7 +131,7 @@ const PLATE_FEATURE_BYTES: usize = 48;
 /// shader2d's WindowInfo UBO: [size/clip vec4][bevel-profile meta vec4]
 /// [8 vec4 of profile slope samples].
 // [size/clip vec4][carve profile meta + 8 vec4][roll profile meta + 8 vec4].
-const WINDOW_INFO_BYTES: vk::DeviceSize = 304;
+const WINDOW_INFO_BYTES: vk::DeviceSize = 320;
 
 pub(crate) struct AllocatedBuffer {
     pub(crate) buffer: vk::Buffer,
@@ -251,6 +251,10 @@ pub struct VkRenderer {
     /// The bevel-profile generation `window_info` was last written with —
     /// `draw_frame_2d` rewrites the UBO when the layout global moves on.
     profile_gen: u64,
+    /// The pinned relief heights (carve drop, roll rise) in physical px as
+    /// last uploaded in WindowInfo — compared each frame, since editors set
+    /// them straight into the style registry with no generation counter.
+    relief_uploaded: (f32, f32),
     /// Same for the edge (roll) profile LUT.
     roll_profile_gen: u64,
     plate_features: AllocatedBuffer,
@@ -866,6 +870,7 @@ impl VkRenderer {
             backdrop_sampler,
             window_info,
             profile_gen: 0,
+            relief_uploaded: (0.0, 0.0),
             roll_profile_gen: 0,
             plate_features,
             frames,
@@ -902,10 +907,19 @@ impl VkRenderer {
         (self.corner_radius_px * crate::layout::corner_span_factor()).min(cap)
     }
 
+    /// The pinned relief heights in physical px, 0 = follow the width.
+    fn relief_px(&self) -> (f32, f32) {
+        let s = crate::scale::scale_factor().max(0.001);
+        (
+            crate::layout::bevel_height().map_or(0.0, |h| h * s),
+            crate::layout::roll_height().map_or(0.0, |h| h * s),
+        )
+    }
+
     fn write_window_info(&mut self) {
         // [size/clip vec4][carve profile meta vec4][8 vec4 carve slopes]
-        // [roll profile meta vec4][8 vec4 roll slopes] — must stay in
-        // lockstep with shader2d's WindowInfo.
+        // [roll profile meta vec4][8 vec4 roll slopes][relief heights vec4]
+        // — must stay in lockstep with shader2d's WindowInfo.
         let mut data = [0.0f32; WINDOW_INFO_BYTES as usize / 4];
         data[0] = self.extent.width as f32;
         data[1] = self.extent.height as f32;
@@ -921,6 +935,10 @@ impl VkRenderer {
             data[41] = crate::layout::BEVEL_PROFILE_SAMPLES as f32;
             data[44..44 + slopes.len()].copy_from_slice(&slopes);
         }
+        let relief = self.relief_px();
+        data[76] = relief.0;
+        data[77] = relief.1;
+        self.relief_uploaded = relief;
         self.profile_gen = crate::layout::bevel_profile_generation();
         self.roll_profile_gen = crate::layout::roll_profile_generation();
         if let Some(allocation) = self.window_info.allocation.as_mut() {
@@ -1543,6 +1561,7 @@ impl VkRenderer {
             // both are valid profiles, so the one-frame mix is benign.
             if self.profile_gen != crate::layout::bevel_profile_generation()
                 || self.roll_profile_gen != crate::layout::roll_profile_generation()
+                || self.relief_uploaded != self.relief_px()
             {
                 self.write_window_info();
             }
diff --git a/src/vk/shader2d.wgsl b/src/vk/shader2d.wgsl
index 842133a..7f7e2da 100644
--- a/src/vk/shader2d.wgsl
+++ b/src/vk/shader2d.wgsl
@@ -27,6 +27,13 @@ struct WindowInfo {
     // the roll's descent progress: 0 at the face join, 1 at the silhouette.
     roll_meta: vec4f,
     roll_profile: array<vec4f, 8>,
+    // Pinned relief heights in physical px (cce_ui::layout::bevel_height /
+    // roll_height): x = a carve's drop, y = the plate roll's rise. 0 = follow
+    // the wall width — RECESS_DEPTH × width for a carve, a quarter-round of
+    // radius width for the roll. Divided by the batch's own wall width
+    // (p_light.w) they become the slope scale, so a pinned 0.5 mm drop is
+    // the same geometry whatever wall it is cut with.
+    relief_meta: vec4f,
 }
 
 @group(0) @binding(2) var<uniform> window_info: WindowInfo;
@@ -268,6 +275,14 @@ fn roll_spec_wrap(sv: vec2f) -> f32 {
 const ROLL_CUT: f32 = 0.8;
 
 fn roll_slope(f: f32) -> f32 {
+    let t = max(rrect_clip.p_light.w, 0.001);
+    let rr = select(1.0, window_info.relief_meta.y / t, window_info.relief_meta.y > 0.0);
+    return roll_slope_unit(f) * rr;
+}
+
+// The unit-rise roll (a quarter-round of radius t, or the custom edge LUT);
+// roll_slope scales it by the pinned rise.
+fn roll_slope_unit(f: f32) -> f32 {
     // Custom edge profile: sample the uploaded ramp LUT. Face pixels saturate
     // at f = 0 (the roll band's interior end), so taper the slope to zero
     // there or every face pixel would inherit the curve's start slope; the
@@ -671,6 +686,9 @@ fn plate_shade(frag: vec2f, vcol: vec4f) -> vec4f {
         wedge = select(0.0, 1.0, rel <= 1.5707964);
     }
     let u = clamp(fd / t + 0.5, 0.0, 1.0);
+    // Drop over run: the pinned height against THIS carve's wall, else the
+    // analytic ratio (the tessellator's CSG features apply the same rule).
+    let cd = select(RECESS_DEPTH, window_info.relief_meta.x / t, window_info.relief_meta.x > 0.0);
     var slope = 0.0;
     var curv = 0.0;
     if (eff == MODE_RIDGE || eff == MODE_TROUGH) {
@@ -684,7 +702,7 @@ fn plate_shade(frag: vec2f, vcol: vec4f) -> vec4f {
         let w = clamp(select(2.0 * u, 2.0 - 2.0 * u, u > 0.5), 0.0, 1.0);
         let up = select(-1.0, 1.0, eff == MODE_RIDGE);
         let rising = select(-1.0, 1.0, u <= 0.5) * up;
-        slope = rising * 0.5 * RECESS_DEPTH * 2.0 * carve_slope(w);
+        slope = rising * 0.5 * cd * 2.0 * carve_slope(w);
         // Each half-wall is a boss wall: concave fillet at its base, convex
         // shoulder toward the crest — and ZERO at the plateaus and crest, so
         // flat ground composites to exactly nothing (a constant term here
@@ -698,7 +716,7 @@ fn plate_shade(frag: vec2f, vcol: vec4f) -> vec4f {
         // normally, smootherstep (zero second derivative at the plateaus)
         // under a continuous-curvature corner_shape — shading eases in and out
         // instead of starting on a line.
-        slope = dir * RECESS_DEPTH * carve_slope(u);
+        slope = dir * cd * carve_slope(u);
         // Curvature: the convex shoulder catches ambient light, the concave
         // fillet self-occludes — on the outer half for a recess, inner for a
         // boss.