GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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.