GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
material: the type, the Finish rename, and one encoding function (RFC step 1)
scene/material.rs: Material { tint, frost, finish }. Frost is an enum —
Opaque, or Frosted { compression, refraction, radius } — so an opaque
plate has no recipe rather than a zero one; radius rides along from the
start at DEFAULT_RADIUS (today's kernel) so the shape changes once, and
is unread until step 3. Finish is relief_shade::Material moved and
renamed; relief_shade re-exports it under both names through step 2.
Material::fill_tint(tint, frost, role) is now the only place a negative
alpha is written. PlateSpec::fill and color::param_plate_fill call it;
ControlPlate::face_from_fill spells Material::control_face. Rung
defaults root()/pane()/control() resolve from the getters the rungs read
today, and the pane test pins pane().fill(Nested) to param_plate_fill's
old arithmetic exactly. Nothing on screen moves: no emitted prim or
signature changed.
The finish's three literals (spec 0.4, shininess 24, curvature 0.2)
become color::finish_* getters with setters, defaults unchanged, no
config path yet.
Found on the way: plate_color, plate_border_color,
plate_border_thickness, plate_bevel_width and plate_blur read their
RwLock directly, so a test's per-thread set_plate_blur(true) was
invisible to plate_blur(). They go through style_read now — identical
outside cfg(test).
Co-Authored-By: Claude Fable 5.1 <[email protected]>
CLAUDE.md | 12 +-
docs/rfc-material.md | 7 +-
src/backend/window_runner.rs | 2 +-
src/bin/cce-relief.rs | 2 +-
src/color.rs | 73 +++++----
src/scene/material.rs | 342 +++++++++++++++++++++++++++++++++++++++++++
src/scene/mod.rs | 2 +
src/scene/paint.rs | 27 ++--
src/scene/relief_shade.rs | 72 +++------
9 files changed, 439 insertions(+), 100 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index bb879c8..b026b89 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -250,6 +250,14 @@ What this buys, and where the code is heading:
and RangeSlider (one stop, two ends: arrows step the focused end, Up / Down
switch ends) are wells. A new focusable widget declares its role and handles `FocusIn`
/ `FocusOut`.
+- **What a plate is made of is a `scene::Material`** — tint, `Frost` (opaque, or
+ frosted with compression / refraction / radius) and `Finish` (how it answers the
+ light: the old `relief_shade::Material`). `docs/rfc-material.md` is the design and
+ its phase tracker. `Material::fill_tint` is the ONE place the blur-behind sentinel
+ (a negative alpha) is written; `PlateSpec::fill` and `param_plate_fill` call it.
+ Rung defaults: `Material::root()` / `pane()` / `control()`; a well floor is
+ `host.floor(lifted)`. Step 1 only — the rungs still carry `color` + `blur` until
+ step 2 threads the type through.
- **One plate spec per rung, not five copies.** The root and pane rungs are
`scene::paint::PlateSpec` (RFC 7b, painted by `PaintCtx::plate`). The
control rung is `scene::paint::ControlPlate` (re-exported from `widget`):
@@ -484,11 +492,11 @@ 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
+length: it is the light strength (`bevel_depth` → `Finish.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
+shading twin (`Finish.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
diff --git a/docs/rfc-material.md b/docs/rfc-material.md
index 44c3915..9475e1d 100644
--- a/docs/rfc-material.md
+++ b/docs/rfc-material.md
@@ -380,7 +380,12 @@ material carries the default and no pixel moves.
Each step's exit test is named. Steps 1–2 must be **prim-identical** against a display-list
dump of every client's default view — the technique the `ControlPlate` migration used.
-**Step 1 — the type, the rename, the encoding.**
+**Step 1 — the type, the rename, the encoding.** *DONE 2026-09-20.* As specified, plus
+one thing found on the way: five plate getters (`plate_blur` among them) read their
+`RwLock` directly and so could not see a test's per-thread write; they go through
+`style_read` now. `Finish` lives in `material.rs`; `relief_shade` re-exports it and
+keeps the `Material` alias. `Frost::Frosted` already carries `radius` at
+`DEFAULT_RADIUS`, unread until step 3.
- Add `scene/material.rs`: `Material`, `Frost`, `Finish` (= `relief_shade::Material`
moved and renamed; `relief_shade` keeps a `pub use` so `cce-relief`, `vk/rt.rs`,
`cce-designer/geometry.rs` and `vk_smoke.rs` need no edit until they choose to).
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index d28a263..b1ee2e9 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -1837,7 +1837,7 @@ pub fn tessellate_display_list(
// 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::scene::relief_shade::Material::from_style().to_array();
+ let plate_mat = crate::scene::material::Finish::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 3f0a577..cbc22bf 100644
--- a/src/bin/cce-relief.rs
+++ b/src/bin/cce-relief.rs
@@ -709,7 +709,7 @@ fn draw_section(pc: &mut PaintCtx, rect: Rect, profile: &ProfileKnobs, shape: Sh
{
let walls = shape.walls();
let light = relief_shade::light_vector();
- let mat = relief_shade::Material::from_style();
+ let mat = cce_ui::scene::material::Finish::from_style();
// Follow the knobs ONLY when a custom profile is actually installed.
// Until one is, the shader runs its analytic branch, and predicting
// from the knob curve instead quietly disagrees with it. The carve case
diff --git a/src/color.rs b/src/color.rs
index af48fda..805d9a7 100644
--- a/src/color.rs
+++ b/src/color.rs
@@ -896,18 +896,15 @@ pub fn set_param_bg_color(color: [f32; 4]) {
style_write(&PARAM_BG_COLOR, color);
}
-/// The params plate's final fill as the renderer consumes it: the tint scaled
-/// by the global plate opacity, alpha negated as the blur-behind marker when
-/// plate blur is on. The single source both `ParametersBg`'s own plate and any
-/// surface that wants to match it (the designer's node bodies) draw from, so
-/// they track a live retint / opacity / blur toggle together.
+/// The params plate's final fill as the renderer consumes it: the pane rung's
+/// material ([`crate::scene::Material::pane`] — the tint at the global plate
+/// opacity, frosted under plate blur) encoded for a nested plate. The single
+/// source both `ParametersBg`'s own plate and any surface that wants to match
+/// it (the designer's node bodies) draw from, so they track a live retint /
+/// opacity / blur toggle together.
pub fn param_plate_fill() -> [f32; 4] {
- let mut c = param_bg_color();
- c[3] *= crate::layout::plate_opacity();
- if plate_blur() {
- c[3] = -c[3].abs();
- }
- c
+ use crate::scene::material::{Material, PlateRole};
+ Material::pane().fill(PlateRole::Nested)
}
pub const PANEL_MENU_BG: [f32; 4] = [0.08, 0.08, 0.12, 1.0];
@@ -1663,10 +1660,7 @@ static PLATE_BEVEL_WIDTH: RwLock<f32> = RwLock::new(6.0);
pub fn plate_color() -> Option<[f32; 4]> {
load_colors_once();
- if let Ok(lock) = PLATE_COLOR.read() {
- return *lock;
- }
- None
+ style_read(&PLATE_COLOR)
}
pub fn set_plate_color(c: Option<[f32; 4]>) {
@@ -1675,10 +1669,7 @@ pub fn set_plate_color(c: Option<[f32; 4]>) {
pub fn plate_border_color() -> Option<[f32; 4]> {
load_colors_once();
- if let Ok(lock) = PLATE_BORDER_COLOR.read() {
- return *lock;
- }
- None
+ style_read(&PLATE_BORDER_COLOR)
}
pub fn set_plate_border_color(c: Option<[f32; 4]>) {
@@ -1687,10 +1678,7 @@ pub fn set_plate_border_color(c: Option<[f32; 4]>) {
pub fn plate_border_thickness() -> f32 {
load_colors_once();
- if let Ok(lock) = PLATE_BORDER_THICKNESS.read() {
- return *lock;
- }
- 1.0
+ style_read(&PLATE_BORDER_THICKNESS)
}
pub fn set_plate_border_thickness(t: f32) {
@@ -1699,10 +1687,7 @@ pub fn set_plate_border_thickness(t: f32) {
pub fn plate_bevel_width() -> f32 {
load_colors_once();
- if let Ok(lock) = PLATE_BEVEL_WIDTH.read() {
- return *lock;
- }
- 6.0
+ style_read(&PLATE_BEVEL_WIDTH)
}
pub fn set_plate_bevel_width(t: f32) {
@@ -1758,12 +1743,38 @@ pub fn set_plate_refraction(r: f32) {
static PLATE_BLUR: RwLock<bool> = RwLock::new(false);
+/// The finish's three fixed terms — specular strength, shininess exponent,
+/// curvature/AO strength — as `scene::material::Finish::from_style` reads
+/// them. Until 2026-09-20 these were literals in the finish constructor
+/// (0.4 / 24 / 0.2); the getters exist so the DE's plastic can be edited and
+/// so a named material (RFC material, step 4) has somewhere to land. No config
+/// path yet: the defaults ARE the shipped look.
+static FINISH_SPEC: RwLock<f32> = RwLock::new(0.4);
+static FINISH_SHININESS: RwLock<f32> = RwLock::new(24.0);
+static FINISH_CURVATURE: RwLock<f32> = RwLock::new(0.2);
+
+pub fn finish_spec() -> f32 {
+ style_read(&FINISH_SPEC)
+}
+pub fn set_finish_spec(v: f32) {
+ style_write(&FINISH_SPEC, v.max(0.0));
+}
+pub fn finish_shininess() -> f32 {
+ style_read(&FINISH_SHININESS)
+}
+pub fn set_finish_shininess(v: f32) {
+ style_write(&FINISH_SHININESS, v.max(1.0));
+}
+pub fn finish_curvature() -> f32 {
+ style_read(&FINISH_CURVATURE)
+}
+pub fn set_finish_curvature(v: f32) {
+ style_write(&FINISH_CURVATURE, v.max(0.0));
+}
+
pub fn plate_blur() -> bool {
load_colors_once();
- if let Ok(lock) = PLATE_BLUR.read() {
- return *lock;
- }
- false
+ style_read(&PLATE_BLUR)
}
pub fn set_plate_blur(b: bool) {
diff --git a/src/scene/material.rs b/src/scene/material.rs
new file mode 100644
index 0000000..f860e8e
--- /dev/null
+++ b/src/scene/material.rs
@@ -0,0 +1,342 @@
+//! Material — what a surface is made of (`docs/rfc-material.md`).
+//!
+//! A [`Material`] is a surface's **tint**, its **frost** (whether and how it
+//! shows what is behind it) and its **finish** (how it answers the DE's
+//! light), carried BY VALUE on the plate made of it. Step 1 of the RFC: the
+//! type exists, the sentinel encoding lives in exactly one function
+//! ([`Material::fill_tint`]), and the rung defaults resolve from the same style
+//! getters the rungs read today — so nothing on screen moves. Step 2 threads
+//! it through `PlateSpec`, `ControlPlate` and the prims.
+//!
+//! What is deliberately NOT here: the light (the scene's, `relief_shade::
+//! light_vector`), the roll width (geometry, per prim as `depth`) and the
+//! carve/roll profiles (per-window uniforms). See the RFC's non-goals.
+
+/// The plastic finish: how a surface answers light. `[shading strength,
+/// specular strength, shininess, curvature/AO strength]` as carried in
+/// `PlatePush.material`, plus the two depth ratios the shader reads from
+/// `WindowInfo`. Formerly `relief_shade::Material`; that module re-exports it
+/// under the old name until step 2 (RFC § 11 (4)).
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct Finish {
+ pub strength: f32,
+ 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. `relief_shade::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 Finish {
+ /// The DE's finish, strength tracking `bevel_depth` against the default,
+ /// the other three from `color::finish_spec` / `finish_shininess` /
+ /// `finish_curvature` (defaults: the literals the shader shipped with).
+ /// 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: crate::color::finish_spec(),
+ shininess: crate::color::finish_shininess(),
+ curvature: crate::color::finish_curvature(),
+ carve_depth: crate::layout::carve_depth_ratio(),
+ roll_height: crate::layout::roll_height_ratio(),
+ }
+ }
+
+ pub fn to_array(self) -> [f32; 4] {
+ [self.strength, self.spec, self.shininess, self.curvature]
+ }
+}
+
+/// Whether and how a surface shows what is behind it.
+///
+/// An enum, not two floats and a bool: an opaque plate has no compression and
+/// no refraction — not zero of each, none — and making the recipe unreachable
+/// when the plate is not frosted is what keeps [`Material::fill`] to one
+/// question.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum Frost {
+ /// The tint alone, composited at its alpha. Not a sample of the backdrop.
+ Opaque,
+ /// Frosted glass: the backdrop blurred, luminance-compressed toward the
+ /// tint's key, tinted at the tint's alpha; the rim refracts.
+ Frosted {
+ /// How hard the blurred backdrop's luminance is pulled toward the
+ /// tint's key — the legibility control. 0..1.
+ /// `style.surface.plate.backdrop_compression` today.
+ compression: f32,
+ /// How far the plate's roll bends what it samples — the objecthood
+ /// control. 0..1. `style.surface.plate.refraction` today.
+ refraction: f32,
+ /// Blur radius (the kernel's sigma) in logical px. Carried from step 1
+ /// so `Frost` changes shape once (RFC § 11 (2)); the renderer reads
+ /// it from step 3. [`Frost::DEFAULT_RADIUS`] is today's literal
+ /// kernel; 0 will be a CLEAR plate — one clean sample, tinted.
+ radius: f32,
+ },
+}
+
+impl Frost {
+ /// The sigma of `resolve_blur`'s kernel as shipped: 7×7 taps at a 5.5 px
+ /// stride, sigma 2 taps — ≈ 11 logical px at scale 1.
+ pub const DEFAULT_RADIUS: f32 = 11.0;
+
+ /// The DE's frost, from the plate-rung keys every frosted surface reads
+ /// today (the window-wide recipe, until step 3 makes it per plate).
+ pub fn from_style() -> Self {
+ Frost::Frosted {
+ compression: crate::color::plate_backdrop_compression(),
+ refraction: crate::color::plate_refraction(),
+ radius: Self::DEFAULT_RADIUS,
+ }
+ }
+
+ /// [`Frost::from_style`] when `on`, else [`Frost::Opaque`] — the shape of
+ /// every `blur: bool` the toolkit carries today.
+ pub fn from_flag(on: bool) -> Self {
+ if on { Self::from_style() } else { Frost::Opaque }
+ }
+
+ pub fn is_frosted(&self) -> bool {
+ matches!(self, Frost::Frosted { .. })
+ }
+}
+
+/// Which frost regime a plate is under: a root plate's frost is the
+/// compositor's blur-behind (its fill stays positive-alpha whatever its
+/// material says), a nested plate's is the in-app pass (the negative-alpha
+/// sentinel). `PlateSpec::role` derives it from the window-corner flags; a
+/// control plate is always nested.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum PlateRole {
+ Root,
+ Nested,
+}
+
+/// What a surface is made of. ~14 floats, copied freely.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct Material {
+ /// Linear RGBA. Alpha is opacity and is non-negative here — the
+ /// blur-behind sentinel is an encoding detail of [`Material::fill`],
+ /// never state.
+ pub tint: [f32; 4],
+ pub frost: Frost,
+ pub finish: Finish,
+}
+
+impl Material {
+ /// An opaque material of `tint` under the DE's finish.
+ pub fn opaque(tint: [f32; 4]) -> Self {
+ Self { tint, frost: Frost::Opaque, finish: Finish::from_style() }
+ }
+
+ pub fn with_tint(mut self, tint: [f32; 4]) -> Self {
+ self.tint = tint;
+ self
+ }
+
+ pub fn with_frost(mut self, frost: Frost) -> Self {
+ self.frost = frost;
+ self
+ }
+
+ pub fn with_finish(mut self, finish: Finish) -> Self {
+ self.finish = finish;
+ self
+ }
+
+ // ---- the rung defaults -------------------------------------------------
+
+ /// The root rung: the window's background, `style.surface.plate.root.
+ /// color` (`color::page_low_color`, whose alpha IS `root_plate_opacity`).
+ /// `Frost::Opaque` on the client side by construction: a root plate's
+ /// frost is the COMPOSITOR's (`plate.root.blur`, which the client never
+ /// reads), and [`Material::fill`] under [`PlateRole::Root`] would ignore
+ /// a `Frosted` here anyway.
+ pub fn root() -> Self {
+ Self::opaque(crate::color::page_low_color())
+ }
+
+ /// The pane rung: the params plate's tint (`style.surface.param.color`)
+ /// at the global plate opacity, frosted when `style.surface.plate.blur`
+ /// says so — exactly what `color::param_plate_fill` resolved before this
+ /// type existed (it now resolves through here).
+ pub fn pane() -> Self {
+ let mut tint = crate::color::param_bg_color();
+ tint[3] *= crate::layout::plate_opacity();
+ Self { tint, frost: Frost::from_flag(crate::color::plate_blur()), finish: Finish::from_style() }
+ }
+
+ /// The control rung: the button fill, never frosted. Control faces under
+ /// the relief stances are laid through a stroke the sentinel cannot reach
+ /// (see `PlateStance::Flat`); frost at this rung is `Flat` only and takes
+ /// the pane's material verbatim ([`Material::flat_control`]).
+ pub fn control() -> Self {
+ Self::opaque(crate::color::button_background_color())
+ }
+
+ // ---- derived materials -------------------------------------------------
+
+ /// The well floor cut into this plate: the same material with the tint
+ /// darkened by `WELL_FLOOR`'s strength (`WELL_FLOOR_LIFTED`'s when
+ /// `lifted`, the hover cue). Frost and finish carried through — a well in
+ /// glass is deeper glass (RFC § 11 (3)). Over an opaque, fully covering
+ /// plate this is the darkening overlay `PaintCtx::well_floor` draws today,
+ /// exactly; the emission switches to this in step 2.
+ pub fn floor(&self, lifted: bool) -> Material {
+ let overlay = if lifted { crate::color::WELL_FLOOR_LIFTED } else { crate::color::WELL_FLOOR };
+ let keep = 1.0 - overlay[3];
+ let t = self.tint;
+ Material { tint: [t[0] * keep, t[1] * keep, t[2] * keep, t[3]], ..*self }
+ }
+
+ /// A `Flat`-stance control on this pane: the material verbatim. Exists so
+ /// the call site says what it means.
+ pub fn flat_control(&self) -> Material {
+ *self
+ }
+
+ /// A control face from a configured fill, under the rule
+ /// `ControlPlate::face_from_fill` states: an opaque one is the face
+ /// (alpha forced to 1 — a translucent face would blend into the relief's
+ /// shading and read as a second material); a transparent one is `None`,
+ /// the surface below showing as the face (edges only).
+ pub fn control_face(raw: [f32; 4]) -> Option<Material> {
+ (raw[3] > 0.001).then(|| Self::opaque([raw[0], raw[1], raw[2], 1.0]))
+ }
+
+ // ---- the one encoding function ----------------------------------------
+
+ /// The vertex colour the renderer consumes for a plate of this material
+ /// in `role` — see [`Material::fill_tint`].
+ pub fn fill(&self, role: PlateRole) -> [f32; 4] {
+ Self::fill_tint(self.tint, self.frost, role)
+ }
+
+ /// THE place a negative alpha is written. For `tint` under `frost` in
+ /// `role`:
+ /// - [`PlateRole::Root`] → alpha positive whatever `frost` says. A root
+ /// plate's frost is the compositor's blur-behind, never the in-app pass.
+ /// - nested + [`Frost::Frosted`] → the in-app frost pass's negative-alpha
+ /// sentinel, `-|alpha|`.
+ /// - nested + [`Frost::Opaque`] → the tint as is.
+ ///
+ /// Static so a caller holding a tint and a flag (today's `PlateSpec`)
+ /// encodes through the same rule without resolving a finish it does not
+ /// need.
+ pub fn fill_tint(tint: [f32; 4], frost: Frost, role: PlateRole) -> [f32; 4] {
+ let mut c = tint;
+ match role {
+ PlateRole::Root => c[3] = c[3].abs(),
+ PlateRole::Nested if frost.is_frosted() => c[3] = -c[3].abs(),
+ PlateRole::Nested => {}
+ }
+ c
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn frosted() -> Frost {
+ Frost::Frosted { compression: 0.6, refraction: 0.3, radius: Frost::DEFAULT_RADIUS }
+ }
+
+ /// The encoding rule, stated once: root stays positive whatever the
+ /// frost, nested frost is the sentinel, nested opaque passes through.
+ #[test]
+ fn fill_encodes_by_role() {
+ let tint = [0.1, 0.2, 0.3, 0.8];
+ assert_eq!(Material::fill_tint(tint, frosted(), PlateRole::Root)[3], 0.8, "root frost is the compositor's");
+ assert_eq!(Material::fill_tint(tint, Frost::Opaque, PlateRole::Root)[3], 0.8);
+ assert_eq!(Material::fill_tint(tint, frosted(), PlateRole::Nested)[3], -0.8, "nested frost = sentinel");
+ assert_eq!(Material::fill_tint(tint, Frost::Opaque, PlateRole::Nested), tint, "no frost, no encoding");
+ // A caller that hands a negative alpha in is normalised, not doubled.
+ assert_eq!(Material::fill_tint([0.0, 0.0, 0.0, -0.5], frosted(), PlateRole::Nested)[3], -0.5);
+ assert_eq!(Material::fill_tint([0.0, 0.0, 0.0, -0.5], Frost::Opaque, PlateRole::Root)[3], 0.5);
+ let m = Material::opaque(tint).with_frost(frosted());
+ assert_eq!(m.fill(PlateRole::Nested), Material::fill_tint(tint, frosted(), PlateRole::Nested));
+ }
+
+ /// The pane rung is `param_plate_fill`'s old arithmetic exactly: the
+ /// tint at plate opacity, negated under plate blur.
+ #[test]
+ fn pane_resolves_like_param_plate_fill_did() {
+ let old = |blur: bool| {
+ let mut c = crate::color::param_bg_color();
+ c[3] *= crate::layout::plate_opacity();
+ if blur {
+ c[3] = -c[3].abs();
+ }
+ c
+ };
+ for blur in [false, true] {
+ crate::color::set_plate_blur(blur);
+ assert_eq!(Material::pane().fill(PlateRole::Nested), old(blur), "blur={blur}");
+ assert_eq!(crate::color::param_plate_fill(), old(blur), "blur={blur}");
+ assert_eq!(Material::pane().frost.is_frosted(), blur);
+ }
+ crate::color::set_plate_blur(false);
+ }
+
+ /// The finish defaults are the literals the shader shipped with, and the
+ /// push-constant layout is unchanged.
+ #[test]
+ fn finish_defaults_and_layout() {
+ let f = Finish::from_style();
+ assert_eq!(f.spec, 0.4);
+ assert_eq!(f.shininess, 24.0);
+ assert_eq!(f.curvature, 0.2);
+ assert_eq!(f.to_array(), [f.strength, f.spec, f.shininess, f.curvature]);
+ crate::color::set_finish_spec(0.9);
+ assert_eq!(Finish::from_style().spec, 0.9);
+ crate::color::set_finish_spec(0.4);
+ }
+
+ /// The frost recipe reads the two plate-rung keys and carries the default
+ /// kernel; the flag form is today's `blur: bool`.
+ #[test]
+ fn frost_from_style_and_flag() {
+ crate::color::set_plate_backdrop_compression(0.6);
+ crate::color::set_plate_refraction(0.3);
+ assert_eq!(Frost::from_style(), frosted());
+ assert_eq!(Frost::from_flag(false), Frost::Opaque);
+ assert!(Frost::from_flag(true).is_frosted());
+ crate::color::set_plate_backdrop_compression(0.0);
+ crate::color::set_plate_refraction(0.0);
+ }
+
+ /// A floor darkens the tint by the overlay's strength and keeps
+ /// everything else: alpha, frost, finish.
+ #[test]
+ fn floor_darkens_and_carries_the_frost() {
+ let m = Material::opaque([0.5, 0.5, 0.5, 0.7]).with_frost(frosted());
+ let f = m.floor(false);
+ let keep = 1.0 - crate::color::WELL_FLOOR[3];
+ assert!((f.tint[0] - 0.5 * keep).abs() < 1e-6);
+ assert_eq!(f.tint[3], 0.7);
+ assert_eq!(f.frost, m.frost);
+ assert_eq!(f.finish, m.finish);
+ assert!(m.floor(true).tint[0] > f.tint[0], "lifted rises toward the plate");
+ assert_eq!(m.flat_control(), m);
+ }
+
+ /// The control-face rule: opaque or nothing.
+ #[test]
+ fn control_face_is_opaque_or_none() {
+ let face = Material::control_face([0.2, 0.3, 0.4, 0.5]).expect("a fill is a face");
+ assert_eq!(face.tint, [0.2, 0.3, 0.4, 1.0]);
+ assert_eq!(face.frost, Frost::Opaque);
+ assert!(Material::control_face([0.2, 0.3, 0.4, 0.0]).is_none());
+ assert_eq!(Material::control().frost, Frost::Opaque);
+ assert_eq!(Material::root().frost, Frost::Opaque);
+ }
+}
diff --git a/src/scene/mod.rs b/src/scene/mod.rs
index 164512e..370f470 100644
--- a/src/scene/mod.rs
+++ b/src/scene/mod.rs
@@ -9,10 +9,12 @@ pub mod anim;
pub mod arena;
pub mod heightfield;
pub mod layout;
+pub mod material;
pub mod paint;
pub mod painter;
pub mod relief_shade;
pub mod tree;
pub use arena::{Arena, Node, NodeId};
+pub use material::{Finish, Frost, Material, PlateRole};
pub use tree::WidgetTree;
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index cb26f83..ffc1d71 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -18,6 +18,7 @@
//! follow-ups.
use crate::scene::layout::Rect;
+use crate::scene::material::{Frost, Material, PlateRole};
/// End-cap style for a [`Prim::Vector`], mirroring the toolkit's line caps.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -107,17 +108,18 @@ impl PlateSpec {
self
}
+ /// The frost regime this plate is under: [`PlateRole::Root`] when it IS
+ /// the window's base surface, [`PlateRole::Nested`] otherwise.
+ pub fn role(&self) -> PlateRole {
+ if self.is_root() { PlateRole::Root } else { PlateRole::Nested }
+ }
+
/// The fill with the role-correct frost encoding: root → alpha forced
/// non-negative (the compositor's frost, not ours), nested + `blur` →
- /// the in-app frost pass's negative-alpha sentinel.
+ /// the in-app frost pass's negative-alpha sentinel. The rule itself is
+ /// [`Material::fill_tint`], the one place a negative alpha is written.
pub fn fill(&self) -> [f32; 4] {
- let mut c = self.color;
- if self.is_root() {
- c[3] = c[3].abs();
- } else if self.blur {
- c[3] = -c[3].abs();
- }
- c
+ Material::fill_tint(self.color, Frost::from_flag(self.blur), self.role())
}
}
@@ -239,11 +241,10 @@ impl ControlPlate {
/// relief's shading and read as a second material); a transparent one
/// leaves the surface below as the face (edges only).
pub fn face_from_fill(raw: [f32; 4]) -> [f32; 4] {
- if raw[3] > 0.001 {
- [raw[0], raw[1], raw[2], 1.0]
- } else {
- [0.0; 4]
- }
+ // The rule lives on the material (`Material::control_face`); this is
+ // its `[f32; 4]` spelling until step 2 gives `ControlPlate` a
+ // `face: Option<Material>`.
+ Material::control_face(raw).map_or([0.0; 4], |m| m.tint)
}
}
diff --git a/src/scene/relief_shade.rs b/src/scene/relief_shade.rs
index 93a3b20..a6865d0 100644
--- a/src/scene/relief_shade.rs
+++ b/src/scene/relief_shade.rs
@@ -12,10 +12,17 @@
//! 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.
+//! Two defences: the light vector lives HERE and the finish in
+//! [`crate::scene::material`], and the renderer reads both from there
+//! (`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.
+
+/// The finish — how a surface answers light — moved to `scene::material` as
+/// [`Finish`] (RFC material, § 11 (4)). `Material` survives here as an alias
+/// through step 2 so out-of-crate readers build untouched.
+pub use crate::scene::material::Finish;
+pub use crate::scene::material::Finish as Material;
/// Ambient floor of the plate lighting model. Mirrors `PLATE_AMBIENT`.
pub const PLATE_AMBIENT: f32 = 0.55;
@@ -40,46 +47,9 @@ pub enum CarveMode {
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,
- /// 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 {
- /// 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,
- carve_depth: crate::layout::carve_depth_ratio(),
- roll_height: crate::layout::roll_height_ratio(),
- }
- }
-
- 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`].
+/// [`Finish::from_style`].
pub fn light_vector() -> [f32; 3] {
let az = crate::layout::light_source_position();
let el = std::f32::consts::FRAC_PI_4;
@@ -106,7 +76,7 @@ pub fn analytic_carve_slope(v: f32) -> f32 {
}
/// 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 {
+pub fn roll_spec(sv: [f32; 2], light: [f32; 3], mat: &Finish) -> f32 {
let m = (sv[0] * sv[0] + sv[1] * sv[1]).sqrt();
if m < 1e-5 {
return 0.0;
@@ -142,7 +112,7 @@ pub fn carve_shade(
facing: [f32; 2],
slope_at: &dyn Fn(f32) -> f32,
light: [f32; 3],
- mat: &Material,
+ mat: &Finish,
) -> f32 {
let u = u.clamp(0.0, 1.0);
let (slope, curv) = match mode {
@@ -207,7 +177,7 @@ pub fn plate_surface(
facing: [f32; 2],
roll_slope_at: &dyn Fn(f32) -> f32,
light: [f32; 3],
- mat: &Material,
+ mat: &Finish,
) -> Option<[f32; 3]> {
if !(0.0..=1.0).contains(&f) {
return None;
@@ -279,7 +249,7 @@ mod tests {
#[test]
fn plate_face_is_untouched() {
let light = light_vector();
- let mat = Material::from_style();
+ let mat = Finish::from_style();
let base = [0.3f32, 0.4, 0.5];
let out = plate_surface(base, 0.0, [-1.0, 0.0], &analytic_roll_slope, light, &mat).unwrap();
for i in 0..3 {
@@ -293,7 +263,7 @@ mod tests {
#[test]
fn flat_ground_shades_to_zero() {
let light = light_vector();
- let mat = Material::from_style();
+ let mat = Finish::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);
@@ -307,9 +277,9 @@ mod tests {
#[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();
+ let shallow = Finish { carve_depth: 0.3, ..Finish::from_style() };
+ let deep = Finish { carve_depth: 1.2, ..Finish::from_style() };
+ let at = |m: &Finish| 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);
@@ -327,7 +297,7 @@ mod tests {
#[test]
fn ridge_and_trough_oppose_where_the_wall_is_steep() {
let light = light_vector();
- let mat = Material::from_style();
+ let mat = Finish::from_style();
let sample = |m: CarveMode, u: f32| {
carve_shade(m, u, [-1.0, 0.0], &analytic_carve_slope, light, &mat)
};