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

src/scene/material.rs (33.5K)

  1 //! Material — what a surface is made of (`docs/rfc-material.md`).
  2 //!
  3 //! A [`Material`] is a surface's **tint**, its **frost** (whether and how it
  4 //! shows what is behind it) and its **finish** (how it answers the DE's
  5 //! light), carried BY VALUE on the plate made of it. Step 1 of the RFC: the
  6 //! type exists, the sentinel encoding lives in exactly one function
  7 //! ([`Material::fill_tint`]), and the rung defaults resolve from the same style
  8 //! getters the rungs read today — so nothing on screen moves. Step 2 threads
  9 //! it through `PlateSpec`, `ControlPlate` and the prims.
 10 //!
 11 //! What is deliberately NOT here: the light (the scene's, `relief_shade::
 12 //! light_vector`), the roll width (geometry, per prim as `depth`) and the
 13 //! carve/roll profiles (per-window uniforms). See the RFC's non-goals.
 14 
 15 /// The plastic finish: how a surface answers light. `[shading strength,
 16 /// specular strength, shininess, curvature/AO strength]` as carried in
 17 /// `PlatePush.material`, plus the two depth ratios the shader reads from
 18 /// `WindowInfo`. Formerly `relief_shade::Material`; that module re-exports it
 19 /// under the old name until step 2 (RFC § 11 (4)).
 20 #[derive(Clone, Copy, Debug, PartialEq)]
 21 pub struct Finish {
 22     pub strength: f32,
 23     pub spec: f32,
 24     pub shininess: f32,
 25     pub curvature: f32,
 26     /// A carve's drop over its run (`layout::carve_depth_ratio`): the
 27     /// geometry the slopes are scaled by. `relief_shade::RECESS_DEPTH` unless
 28     /// a height is pinned. Not in `to_array` — the shader reads it from
 29     /// `WindowInfo`.
 30     pub carve_depth: f32,
 31     /// The plate roll's rise over its run (`layout::roll_height_ratio`):
 32     /// 1 for the quarter-round.
 33     pub roll_height: f32,
 34 }
 35 
 36 impl Finish {
 37     /// The DE's finish, strength tracking `bevel_depth` against the default,
 38     /// the other three from `color::finish_spec` / `finish_shininess` /
 39     /// `finish_curvature` (defaults: the literals the shader shipped with).
 40     /// This is the ONE definition — the renderer's push constants come from
 41     /// here too.
 42     pub fn from_style() -> Self {
 43         Self {
 44             strength: crate::layout::bevel_depth() / 0.15,
 45             spec: crate::color::finish_spec(),
 46             shininess: crate::color::finish_shininess(),
 47             curvature: crate::color::finish_curvature(),
 48             carve_depth: crate::layout::carve_depth_ratio(),
 49             roll_height: crate::layout::roll_height_ratio(),
 50         }
 51     }
 52 
 53     pub fn to_array(self) -> [f32; 4] {
 54         [self.strength, self.spec, self.shininess, self.curvature]
 55     }
 56 }
 57 
 58 /// Whether and how a surface shows what is behind it.
 59 ///
 60 /// An enum, not two floats and a bool: an opaque plate has no compression and
 61 /// no refraction — not zero of each, none — and making the recipe unreachable
 62 /// when the plate is not frosted is what keeps [`Material::fill`] to one
 63 /// question.
 64 #[derive(Clone, Copy, Debug, PartialEq)]
 65 pub enum Frost {
 66     /// The tint alone, composited at its alpha. Not a sample of the backdrop.
 67     Opaque,
 68     /// Frosted glass: the backdrop blurred, luminance-compressed toward the
 69     /// tint's key, tinted at the tint's alpha; the rim refracts.
 70     Frosted {
 71         /// How hard the blurred backdrop's luminance is pulled toward the
 72         /// tint's key — the legibility control. 0..1.
 73         /// `style.surface.plate.backdrop_compression` today.
 74         compression: f32,
 75         /// How far the plate's roll bends what it samples — the objecthood
 76         /// control. 0..1. `style.surface.plate.refraction` today.
 77         refraction: f32,
 78         /// Blur radius — the kernel's sigma — in logical px.
 79         /// [`Frost::DEFAULT_RADIUS`] is the kernel every frosted plate had;
 80         /// 0 is a CLEAR plate: one clean sample, tinted.
 81         radius: f32,
 82     },
 83 }
 84 
 85 impl Frost {
 86     /// The kernel every frosted plate had, as a sigma in logical px.
 87     ///
 88     /// Before the recipe was per plate, `resolve_blur` sampled a 7×7 kernel
 89     /// at a fixed 5.5 PHYSICAL px stride (sigma two taps = 11 physical px)
 90     /// — half the blur on a scale-2 panel that it was on a scale-1 one, and
 91     /// the panel every frosted surface was tuned on is scale 2. A material
 92     /// cannot know the scale, so the default is stated in logical px at the
 93     /// value that reproduces the panel exactly: 5.5 logical = 11 physical at
 94     /// scale 2. A scale-1 display now gets the same logical blur instead of
 95     /// twice it.
 96     pub const DEFAULT_RADIUS: f32 = 5.5;
 97 
 98     /// Fixed-point width of `compression` and `refraction` inside one push
 99     /// float: `c·4095·4096 + r·4095` is an integer below 2²⁴, exact in f32.
100     /// Mirrors the shader's `FROST_PACK_MAX` / `FROST_PACK_BASE`.
101     pub const PACK_MAX: f32 = 4095.0;
102     pub const PACK_BASE: f32 = 4096.0;
103 
104     /// The recipe as the plate branch reads it: `[p_host.z, p_host.w]` —
105     /// compression and refraction packed in `z`, the blur radius in `w` as
106     /// the kernel sigma in PHYSICAL px (the shader samples the backdrop in
107     /// physical px). `Opaque` packs to zeros: nothing reads them, and a
108     /// plate that was never frosted pushes the bytes it always did.
109     pub fn pack(&self, scale: f32) -> [f32; 2] {
110         match *self {
111             Frost::Opaque => [0.0, 0.0],
112             Frost::Frosted { compression, refraction, radius } => {
113                 let q = |v: f32| (v.clamp(0.0, 1.0) * Self::PACK_MAX).round();
114                 [q(compression) * Self::PACK_BASE + q(refraction), radius.max(0.0) * scale]
115             }
116         }
117     }
118 
119     /// The Rust twin of the shader's unpack: `(compression, refraction)`
120     /// from a packed `z`.
121     pub fn unpack(z: f32) -> (f32, f32) {
122         let hi = (z / Self::PACK_BASE).floor();
123         (hi / Self::PACK_MAX, (z - hi * Self::PACK_BASE) / Self::PACK_MAX)
124     }
125 
126     /// The DE's frost recipe: the pane rung's, when its bound material is
127     /// frosted (`plate material="glass"`), else the default material's
128     /// plate-rung keys (`style.surface.plate.backdrop_compression` /
129     /// `refraction` / `radius`). What `from_fill` and `popover` frost with.
130     pub fn from_style() -> Self {
131         if let Some(f @ Frost::Frosted { .. }) = Material::bound(PlateRung::Pane).map(|m| m.frost) {
132             return f;
133         }
134         Frost::Frosted {
135             compression: crate::color::plate_backdrop_compression(),
136             refraction: crate::color::plate_refraction(),
137             radius: crate::color::plate_frost_radius(),
138         }
139     }
140 
141     /// [`Frost::from_style`] when `on`, else [`Frost::Opaque`] — the shape of
142     /// every `blur: bool` the toolkit carries today.
143     pub fn from_flag(on: bool) -> Self {
144         if on { Self::from_style() } else { Frost::Opaque }
145     }
146 
147     pub fn is_frosted(&self) -> bool {
148         matches!(self, Frost::Frosted { .. })
149     }
150 }
151 
152 /// The three rungs of the plate ladder a material can be bound to in
153 /// config (`docs/rfc-material.md` § 5): `plate { root material="…" }`,
154 /// `plate material="…"` and `control material="…"`.
155 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
156 pub enum PlateRung {
157     Root,
158     Pane,
159     Control,
160 }
161 
162 /// A named material as config spells it — every field optional, resolved
163 /// against the rung's legacy material by [`MaterialDef::resolve`]:
164 ///
165 /// ```kdl
166 /// style { surface { material {
167 ///     glass {
168 ///         color (rgba)"#05050840"
169 ///         frost backdrop_compression=(f64)0.6 refraction=(f64)0.3 radius=(f64)5.5
170 ///         finish light=(f64)0.15 spec=(f64)0.4 shininess=(f64)24.0 curvature=(f64)0.2
171 ///     }
172 /// } } }
173 /// ```
174 ///
175 /// A node with no `frost` child is opaque — not "frosted at zero", none. A
176 /// missing `color` keeps the rung's tint; a missing `finish` key keeps the
177 /// DE's. `light` is the finish strength in the units `style.surface.relief.
178 /// depth` uses (0.15 = the default strength of 1).
179 #[derive(Clone, Debug, Default, PartialEq)]
180 pub struct MaterialDef {
181     pub tint: Option<[f32; 4]>,
182     pub frost: Option<FrostDef>,
183     pub light: Option<f32>,
184     pub spec: Option<f32>,
185     pub shininess: Option<f32>,
186     pub curvature: Option<f32>,
187 }
188 
189 /// The `frost` child of a material node: present means frosted, each knob
190 /// defaulting (0, 0, [`Frost::DEFAULT_RADIUS`]).
191 #[derive(Clone, Copy, Debug, Default, PartialEq)]
192 pub struct FrostDef {
193     pub compression: Option<f32>,
194     pub refraction: Option<f32>,
195     pub radius: Option<f32>,
196 }
197 
198 impl MaterialDef {
199     /// The material this definition names, over `base` — the rung's legacy
200     /// material, which supplies everything the node leaves unsaid.
201     pub fn resolve(&self, base: Material) -> Material {
202         let frost = match self.frost {
203             Some(f) => Frost::Frosted {
204                 compression: f.compression.unwrap_or(0.0).clamp(0.0, 1.0),
205                 refraction: f.refraction.unwrap_or(0.0).clamp(0.0, 1.0),
206                 radius: f.radius.unwrap_or(Frost::DEFAULT_RADIUS).max(0.0),
207             },
208             None => Frost::Opaque,
209         };
210         let mut finish = base.finish;
211         if let Some(l) = self.light {
212             finish.strength = l / 0.15;
213         }
214         if let Some(v) = self.spec {
215             finish.spec = v.max(0.0);
216         }
217         if let Some(v) = self.shininess {
218             finish.shininess = v.max(1.0);
219         }
220         if let Some(v) = self.curvature {
221             finish.curvature = v.max(0.0);
222         }
223         Material { tint: self.tint.unwrap_or(base.tint), frost, finish }
224     }
225 }
226 
227 /// Which frost regime a plate is under: a root plate's frost is the
228 /// compositor's blur-behind (its fill stays positive-alpha whatever its
229 /// material says), a nested plate's is the in-app pass (the negative-alpha
230 /// sentinel). `PlateSpec::role` derives it from the window-corner flags; a
231 /// control plate is always nested.
232 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
233 pub enum PlateRole {
234     Root,
235     Nested,
236 }
237 
238 /// What a surface is made of. ~14 floats, copied freely.
239 #[derive(Clone, Copy, Debug, PartialEq)]
240 pub struct Material {
241     /// Linear RGBA. Alpha is opacity and is non-negative here — the
242     /// blur-behind sentinel is an encoding detail of [`Material::fill`],
243     /// never state.
244     pub tint: [f32; 4],
245     pub frost: Frost,
246     pub finish: Finish,
247 }
248 
249 impl Material {
250     /// An opaque material of `tint` under the DE's finish.
251     pub fn opaque(tint: [f32; 4]) -> Self {
252         Self { tint, frost: Frost::Opaque, finish: Finish::from_style() }
253     }
254 
255     pub fn with_tint(mut self, tint: [f32; 4]) -> Self {
256         self.tint = tint;
257         self
258     }
259 
260     pub fn with_frost(mut self, frost: Frost) -> Self {
261         self.frost = frost;
262         self
263     }
264 
265     pub fn with_finish(mut self, finish: Finish) -> Self {
266         self.finish = finish;
267         self
268     }
269 
270     // ---- the rung defaults -------------------------------------------------
271 
272     /// The root rung: `plate { root material="…" }` when bound, else the
273     /// window's background, `style.surface.plate.root.color`
274     /// (`color::page_low_color`, whose alpha IS `root_plate_opacity`).
275     /// `Frost::Opaque` on the client side by construction: a root plate's
276     /// frost is the COMPOSITOR's (`plate.root.blur`, which the client never
277     /// reads), and [`Material::fill`] under [`PlateRole::Root`] would ignore
278     /// a `Frosted` here anyway.
279     pub fn root() -> Self {
280         Self::bound(PlateRung::Root).unwrap_or_else(Self::root_legacy)
281     }
282 
283     /// The pane rung: `plate material="…"` when bound, else the params
284     /// plate's tint (`style.surface.param.color`) at the global plate
285     /// opacity, frosted when `style.surface.plate.blur` says so — exactly
286     /// what `color::param_plate_fill` resolved before this type existed (it
287     /// now resolves through here).
288     pub fn pane() -> Self {
289         Self::bound(PlateRung::Pane).unwrap_or_else(Self::pane_legacy)
290     }
291 
292     /// The control rung: `control material="…"` when bound, else the button
293     /// fill, never frosted. Control faces under the relief stances are laid
294     /// through a stroke the sentinel cannot reach (see `PlateStance::Flat`);
295     /// frost at this rung is `Flat` only and takes the pane's material
296     /// verbatim ([`Material::flat_control`]).
297     pub fn control() -> Self {
298         Self::bound(PlateRung::Control).unwrap_or_else(Self::control_legacy)
299     }
300 
301     /// The rung's material from the legacy keys alone — what every config
302     /// without a `material=` binding resolves to, and the base a bound
303     /// material's unset fields fall back to.
304     pub fn legacy(rung: PlateRung) -> Self {
305         match rung {
306             PlateRung::Root => Self::root_legacy(),
307             PlateRung::Pane => Self::pane_legacy(),
308             PlateRung::Control => Self::control_legacy(),
309         }
310     }
311 
312     fn root_legacy() -> Self {
313         Self::opaque(crate::color::page_low_color())
314     }
315 
316     fn pane_legacy() -> Self {
317         let mut tint = crate::color::param_bg_color();
318         tint[3] *= crate::layout::plate_opacity();
319         let frost = if crate::color::plate_blur() {
320             Frost::Frosted {
321                 compression: crate::color::plate_backdrop_compression(),
322                 refraction: crate::color::plate_refraction(),
323                 radius: crate::color::plate_frost_radius(),
324             }
325         } else {
326             Frost::Opaque
327         };
328         Self { tint, frost, finish: Finish::from_style() }
329     }
330 
331     fn control_legacy() -> Self {
332         Self::opaque(crate::color::button_background_color())
333     }
334 
335     /// The material `rung` is bound to in config, resolved over the rung's
336     /// legacy material — `None` when the rung is unbound. A binding to a
337     /// name no `material` node defines is reported once and treated as
338     /// unbound, so a typo degrades to today's look rather than to nothing.
339     pub fn bound(rung: PlateRung) -> Option<Self> {
340         let name = crate::color::material_binding(rung)?;
341         match crate::color::named_material(&name) {
342             Some(def) => Some(def.resolve(Self::legacy(rung))),
343             None => {
344                 log::warn!("{rung:?} plate rung is bound to material \"{name}\", which no material node defines");
345                 None
346             }
347         }
348     }
349 
350     /// A named material from config, resolved over the pane rung's legacy
351     /// material — for an app that wants a material by name for its own
352     /// surfaces. `None` when no node defines it.
353     pub fn named(name: &str) -> Option<Self> {
354         crate::color::named_material(name).map(|def| def.resolve(Self::pane_legacy()))
355     }
356 
357     /// The legacy bridge: a fill as the renderer consumed it before this
358     /// type existed. A negative alpha is the frost sentinel — `Frosted` at
359     /// the DE recipe, tint alpha `|a|`; otherwise `Opaque` with the colour
360     /// as is. `from_fill(c).fill(Nested) == c` for every `c` a caller could
361     /// hand the old API. For a call site that holds an encoded colour; a
362     /// site that knows what it means says `Material::opaque` / `with_frost`.
363     pub fn from_fill(encoded: [f32; 4]) -> Self {
364         let a = encoded[3];
365         let m = Self::opaque([encoded[0], encoded[1], encoded[2], a.abs()]);
366         if a < 0.0 { m.with_frost(Frost::from_style()) } else { m }
367     }
368 
369     /// The legacy bridge for a FACE slot: a transparent fill is no face at
370     /// all (`None` — the surface below shows through), anything else is
371     /// [`Material::from_fill`] of it. The `|alpha| > 0.001` test every face
372     /// slot applied, stated once.
373     pub fn face(encoded: [f32; 4]) -> Option<Self> {
374         (encoded[3].abs() > 0.001).then(|| Self::from_fill(encoded))
375     }
376 
377     /// This material as a plate in `role` carries it: a root plate's frost
378     /// is the COMPOSITOR's, so under [`PlateRole::Root`] the client-side
379     /// material is opaque — the prim a `PlateSpec` emits carries this, and
380     /// the tessellator encodes every prim as nested.
381     pub fn for_role(&self, role: PlateRole) -> Self {
382         match role {
383             PlateRole::Root => Material { frost: Frost::Opaque, ..*self },
384             PlateRole::Nested => *self,
385         }
386     }
387 
388     /// The popover material: a menu, a context menu, a dropdown's open
389     /// surface. `base`'s colour at `style.surface.menu.opacity`
390     /// (`color::menu_opacity`) — not the colour's own alpha, since a page
391     /// colour is typically opaque and would resolve the frost to a solid
392     /// tint — and frosted at the DE recipe, so a menu shows the content
393     /// beneath it blurred and tinted rather than covering it, with the
394     /// recipe's compression replaced by the menu's own
395     /// (`style.surface.menu.compression`, `color::menu_compression`): a menu
396     /// is read over whatever it opened above, so it holds its key harder
397     /// than a pane does.
398     pub fn popover(base: [f32; 4]) -> Self {
399         let mut frost = Frost::from_style();
400         if let Frost::Frosted { compression, .. } = &mut frost {
401             *compression = crate::color::menu_compression();
402         }
403         Self::opaque([base[0], base[1], base[2], crate::color::menu_opacity()]).with_frost(frost)
404     }
405 
406     // ---- derived materials -------------------------------------------------
407 
408     /// The well floor cut into this plate: the same material with the tint
409     /// darkened by `WELL_FLOOR`'s strength (`WELL_FLOOR_LIFTED`'s when
410     /// `lifted`, the hover cue). Frost and finish carried through — a well in
411     /// glass is deeper glass (RFC § 11 (3)). `PaintCtx::well_floor` draws
412     /// this for a FROSTED host; an opaque host's floor stays the darkening
413     /// overlay, which is this exactly at plate alpha 1 and the honest
414     /// darkening at any other alpha (a darkened fill at the plate's own
415     /// alpha would barely darken a translucent plate).
416     pub fn floor(&self, lifted: bool) -> Material {
417         let overlay = if lifted { crate::color::WELL_FLOOR_LIFTED } else { crate::color::WELL_FLOOR };
418         let keep = 1.0 - overlay[3];
419         let t = self.tint;
420         Material { tint: [t[0] * keep, t[1] * keep, t[2] * keep, t[3]], ..*self }
421     }
422 
423     /// A `Flat`-stance control on this pane: the material verbatim. Exists so
424     /// the call site says what it means.
425     pub fn flat_control(&self) -> Material {
426         *self
427     }
428 
429     /// A control face from a configured fill, under the rule
430     /// the control rung states: an opaque one is the face
431     /// (alpha forced to 1 — a translucent face would blend into the relief's
432     /// shading and read as a second material); a transparent one is `None`,
433     /// the surface below showing as the face (edges only).
434     pub fn control_face(raw: [f32; 4]) -> Option<Material> {
435         (raw[3] > 0.001).then(|| Self::opaque([raw[0], raw[1], raw[2], 1.0]))
436     }
437 
438     // ---- the one encoding function ----------------------------------------
439 
440     /// The vertex colour the renderer consumes for a plate of this material
441     /// in `role` — see [`Material::fill_tint`].
442     pub fn fill(&self, role: PlateRole) -> [f32; 4] {
443         Self::fill_tint(self.tint, self.frost, role)
444     }
445 
446     /// THE place a negative alpha is written. For `tint` under `frost` in
447     /// `role`:
448     /// - [`PlateRole::Root`] → alpha positive whatever `frost` says. A root
449     ///   plate's frost is the compositor's blur-behind, never the in-app pass.
450     /// - nested + [`Frost::Frosted`] → the in-app frost pass's negative-alpha
451     ///   sentinel, `-|alpha|`.
452     /// - nested + [`Frost::Opaque`] → the tint as is.
453     ///
454     /// Static so a caller holding a tint and a flag (today's `PlateSpec`)
455     /// encodes through the same rule without resolving a finish it does not
456     /// need.
457     pub fn fill_tint(tint: [f32; 4], frost: Frost, role: PlateRole) -> [f32; 4] {
458         let mut c = tint;
459         match role {
460             PlateRole::Root => c[3] = c[3].abs(),
461             PlateRole::Nested if frost.is_frosted() => c[3] = -c[3].abs(),
462             PlateRole::Nested => {}
463         }
464         c
465     }
466 }
467 
468 #[cfg(test)]
469 mod tests {
470     use super::*;
471 
472     fn frosted() -> Frost {
473         Frost::Frosted { compression: 0.6, refraction: 0.3, radius: Frost::DEFAULT_RADIUS }
474     }
475 
476     /// The encoding rule, stated once: root stays positive whatever the
477     /// frost, nested frost is the sentinel, nested opaque passes through.
478     #[test]
479     fn fill_encodes_by_role() {
480         let tint = [0.1, 0.2, 0.3, 0.8];
481         assert_eq!(Material::fill_tint(tint, frosted(), PlateRole::Root)[3], 0.8, "root frost is the compositor's");
482         assert_eq!(Material::fill_tint(tint, Frost::Opaque, PlateRole::Root)[3], 0.8);
483         assert_eq!(Material::fill_tint(tint, frosted(), PlateRole::Nested)[3], -0.8, "nested frost = sentinel");
484         assert_eq!(Material::fill_tint(tint, Frost::Opaque, PlateRole::Nested), tint, "no frost, no encoding");
485         // A caller that hands a negative alpha in is normalised, not doubled.
486         assert_eq!(Material::fill_tint([0.0, 0.0, 0.0, -0.5], frosted(), PlateRole::Nested)[3], -0.5);
487         assert_eq!(Material::fill_tint([0.0, 0.0, 0.0, -0.5], Frost::Opaque, PlateRole::Root)[3], 0.5);
488         let m = Material::opaque(tint).with_frost(frosted());
489         assert_eq!(m.fill(PlateRole::Nested), Material::fill_tint(tint, frosted(), PlateRole::Nested));
490     }
491 
492     /// The pane rung is `param_plate_fill`'s old arithmetic exactly: the
493     /// tint at plate opacity, negated under plate blur.
494     #[test]
495     fn pane_resolves_like_param_plate_fill_did() {
496         let _lock = crate::color::test_color_state_lock();
497         let old = |blur: bool| {
498             let mut c = crate::color::param_bg_color();
499             c[3] *= crate::layout::plate_opacity();
500             if blur {
501                 c[3] = -c[3].abs();
502             }
503             c
504         };
505         for blur in [false, true] {
506             crate::color::set_plate_blur(blur);
507             assert_eq!(Material::pane().fill(PlateRole::Nested), old(blur), "blur={blur}");
508             assert_eq!(crate::color::param_plate_fill(), old(blur), "blur={blur}");
509             assert_eq!(Material::pane().frost.is_frosted(), blur);
510         }
511         crate::color::set_plate_blur(false);
512     }
513 
514     /// The finish defaults are the literals the shader shipped with, and the
515     /// push-constant layout is unchanged.
516     #[test]
517     fn finish_defaults_and_layout() {
518         let f = Finish::from_style();
519         assert_eq!(f.spec, 0.4);
520         assert_eq!(f.shininess, 24.0);
521         assert_eq!(f.curvature, 0.2);
522         assert_eq!(f.to_array(), [f.strength, f.spec, f.shininess, f.curvature]);
523         crate::color::set_finish_spec(0.9);
524         assert_eq!(Finish::from_style().spec, 0.9);
525         crate::color::set_finish_spec(0.4);
526     }
527 
528     /// The frost recipe reads the two plate-rung keys and carries the default
529     /// kernel; the flag form is today's `blur: bool`.
530     #[test]
531     fn frost_from_style_and_flag() {
532         let _lock = crate::color::test_color_state_lock();
533         crate::color::set_plate_backdrop_compression(0.6);
534         crate::color::set_plate_refraction(0.3);
535         assert_eq!(Frost::from_style(), frosted());
536         assert_eq!(Frost::from_flag(false), Frost::Opaque);
537         assert!(Frost::from_flag(true).is_frosted());
538         crate::color::set_plate_backdrop_compression(0.0);
539         crate::color::set_plate_refraction(0.0);
540     }
541 
542     /// A floor darkens the tint by the overlay's strength and keeps
543     /// everything else: alpha, frost, finish.
544     #[test]
545     fn floor_darkens_and_carries_the_frost() {
546         let m = Material::opaque([0.5, 0.5, 0.5, 0.7]).with_frost(frosted());
547         let f = m.floor(false);
548         let keep = 1.0 - crate::color::WELL_FLOOR[3];
549         assert!((f.tint[0] - 0.5 * keep).abs() < 1e-6);
550         assert_eq!(f.tint[3], 0.7);
551         assert_eq!(f.frost, m.frost);
552         assert_eq!(f.finish, m.finish);
553         assert!(m.floor(true).tint[0] > f.tint[0], "lifted rises toward the plate");
554         assert_eq!(m.flat_control(), m);
555     }
556 
557     /// The legacy bridge round-trips every fill the old API accepted, and
558     /// the role resolution agrees with the encoding function.
559     #[test]
560     fn from_fill_round_trips_and_for_role_matches_fill() {
561         for c in [[0.1, 0.2, 0.3, 0.8], [0.1, 0.2, 0.3, -0.8], [0.0; 4], [0.5, 0.5, 0.5, 1.0]] {
562             let m = Material::from_fill(c);
563             assert_eq!(m.fill(PlateRole::Nested), c, "{c:?}");
564             assert_eq!(m.frost.is_frosted(), c[3] < 0.0);
565             assert!(m.tint[3] >= 0.0, "tint alpha is never negative");
566             for role in [PlateRole::Root, PlateRole::Nested] {
567                 assert_eq!(m.for_role(role).fill(PlateRole::Nested), m.fill(role), "{c:?} {role:?}");
568             }
569         }
570         assert_eq!(Material::from_fill([0.0, 0.0, 0.0, -0.5]).frost, Frost::from_style());
571         assert!(Material::face([0.3, 0.3, 0.3, 0.0]).is_none(), "transparent = no face");
572         assert!(Material::face([0.3, 0.3, 0.3, -0.5]).is_some_and(|m| m.frost.is_frosted()));
573         assert_eq!(Material::face([0.3, 0.3, 0.3, 0.7]).map(|m| m.tint), Some([0.3, 0.3, 0.3, 0.7]));
574     }
575 
576     /// The pack is exact on its own grid, monotone, and never mixes the two
577     /// halves; the shader's literals are the ones the Rust twin uses.
578     #[test]
579     fn frost_pack_round_trips() {
580         for i in [0u32, 1, 2, 613, 614, 2047, 2048, 4094, 4095] {
581             for j in [0u32, 1, 819, 4095] {
582                 let (c, r) = (i as f32 / Frost::PACK_MAX, j as f32 / Frost::PACK_MAX);
583                 let f = Frost::Frosted { compression: c, refraction: r, radius: 5.5 };
584                 let [z, w] = f.pack(2.0);
585                 let (c2, r2) = Frost::unpack(z);
586                 assert!((c2 - c).abs() < 1e-6 && (r2 - r).abs() < 1e-6, "{i},{j}: {c},{r} -> {c2},{r2}");
587                 assert_eq!(w, 11.0);
588                 assert!(z < (1u32 << 24) as f32, "packed value must stay an exact f32 integer");
589             }
590         }
591         // 0.6 / 0.3 (the designer's recipe) survive to better than a 1/255 step.
592         let (c, r) = Frost::unpack(Frost::Frosted { compression: 0.6, refraction: 0.3, radius: 0.0 }.pack(1.0)[0]);
593         assert!((c - 0.6).abs() < 1.0 / 510.0 && (r - 0.3).abs() < 1.0 / 510.0);
594         assert_eq!(Frost::Opaque.pack(2.0), [0.0, 0.0]);
595         assert_eq!(Frost::Frosted { compression: 0.0, refraction: 0.0, radius: 0.0 }.pack(2.0), [0.0, 0.0]);
596 
597         let wgsl = include_str!("../vk/shader2d.wgsl");
598         let lit = |name: &str| -> f32 {
599             let rest = wgsl.split(&format!("const {name}: f32 = ")).nth(1).unwrap_or_else(|| panic!("{name} missing"));
600             rest.split(';').next().unwrap().trim().parse().unwrap()
601         };
602         assert_eq!(lit("FROST_PACK_MAX"), Frost::PACK_MAX);
603         assert_eq!(lit("FROST_PACK_BASE"), Frost::PACK_BASE);
604         // The droplet's and the raw-vertex fallback's stride is the panel's
605         // default kernel in physical px: DEFAULT_RADIUS × scale 2 / 2.
606         assert_eq!(lit("LEGACY_STRIDE"), Frost::DEFAULT_RADIUS * 2.0 / 2.0);
607     }
608 
609     const DESIGNER_LEGACY: &str = r##"
610         style {
611             surface {
612                 param color=(rgba)"#05050840"
613                 plate backdrop_compression=(f64)0.6 refraction=(f64)0.3 bevel_width=(f64)12.0 blur=(bool)true {
614                     root corner_radius=(i64)24
615                 }
616                 relief depth=(f64)0.08
617             }
618         }
619     "##;
620 
621     const DESIGNER_NAMED: &str = r##"
622         style {
623             surface {
624                 material {
625                     glass {
626                         color (rgba)"#05050840"
627                         frost backdrop_compression=(f64)0.6 refraction=(f64)0.3
628                     }
629                 }
630                 plate material="glass" bevel_width=(f64)12.0 {
631                     root corner_radius=(i64)24
632                 }
633                 relief depth=(f64)0.08
634             }
635         }
636     "##;
637 
638     /// The designer's frosted pane spelled with the legacy keys and as a
639     /// named material bound to the pane rung resolve to the SAME material —
640     /// the step-4 exit test: same Material, same bytes (steps 2–3).
641     #[test]
642     fn named_material_round_trips_the_legacy_spelling() {
643         let _lock = crate::color::test_color_state_lock();
644         crate::layout::lazy_init_style_registry();
645         let _ = crate::color::plate_blur(); // fire the once-per-process load BEFORE the reload
646         crate::layout::set_plate_opacity(1.0);
647         crate::color::reload_colors(DESIGNER_LEGACY);
648         assert_eq!(crate::color::material_binding(PlateRung::Pane), None);
649         let legacy = Material::pane();
650         assert!(legacy.frost.is_frosted());
651         assert!((legacy.tint[3] - 0x40 as f32 / 255.0).abs() < 1e-6, "{:?}", legacy.tint);
652 
653         crate::color::reload_colors(DESIGNER_NAMED);
654         assert_eq!(crate::color::material_binding(PlateRung::Pane).as_deref(), Some("glass"));
655         let named = Material::pane();
656         assert_eq!(named.tint, legacy.tint);
657         assert_eq!(named.finish, legacy.finish);
658         match (named.frost, legacy.frost) {
659             (Frost::Frosted { compression: c1, refraction: r1, radius: d1 }, Frost::Frosted { compression: c2, refraction: r2, radius: d2 }) => {
660                 assert!((c1 - c2).abs() < 1e-6 && (r1 - r2).abs() < 1e-6 && d1 == d2, "{:?} vs {:?}", named.frost, legacy.frost);
661             }
662             other => panic!("{other:?}"),
663         }
664         // The DE recipe follows the bound pane.
665         assert_eq!(Frost::from_style(), named.frost);
666         assert_eq!(Material::named("glass"), Some(named));
667         assert_eq!(Material::named("nope"), None);
668         // The other rungs are unbound and unchanged.
669         assert_eq!(Material::root(), Material::legacy(PlateRung::Root));
670         assert_eq!(Material::control(), Material::legacy(PlateRung::Control));
671         assert_eq!(crate::color::material_names(), vec!["glass".to_string()]);
672         // Bindings and nodes are replaced wholesale by every load, so an
673         // empty document unbinds every rung for the tests that follow.
674         crate::color::reload_colors("");
675         assert_eq!(crate::color::material_binding(PlateRung::Pane), None);
676     }
677 
678     /// A binding wins over the legacy keys, a node without `frost` is opaque
679     /// whatever `plate blur` says, unset fields fall back to the rung, and a
680     /// binding to an undefined name degrades to the legacy material.
681     #[test]
682     fn binding_semantics() {
683         let _lock = crate::color::test_color_state_lock();
684         crate::layout::lazy_init_style_registry();
685         let _ = crate::color::plate_blur(); // fire the once-per-process load BEFORE the reload
686         crate::color::reload_colors(r##"
687             style {
688                 surface {
689                     material {
690                         plastic {
691                             finish spec=(f64)0.25 shininess=(f64)12.0
692                         }
693                         matte {
694                             color (rgba)"#20202080"
695                             finish light=(f64)0.3
696                         }
697                     }
698                     plate blur=(bool)true material="plastic"
699                     relief depth=(f64)0.15 spec=(f64)0.5 shininess=(f64)20.0 curvature=(f64)0.1
700                 }
701                 control material="ghost"
702             }
703         "##);
704         let pane = Material::pane();
705         assert_eq!(pane.frost, Frost::Opaque, "no frost child = opaque, blur flag or not");
706         assert_eq!(pane.tint, Material::legacy(PlateRung::Pane).tint, "no color = the rung's tint");
707         assert_eq!(pane.finish.spec, 0.25);
708         assert_eq!(pane.finish.shininess, 12.0);
709         assert_eq!(pane.finish.curvature, 0.1, "unset finish keys take the DE's (relief curvature)");
710         assert_eq!(Finish::from_style().spec, 0.5, "style.surface.relief.spec is the DE finish");
711         assert_eq!(Material::named("matte").map(|m| m.finish.strength), Some(0.3 / 0.15));
712         assert_eq!(Material::control(), Material::legacy(PlateRung::Control), "unknown name = unbound");
713         assert!(Frost::from_style().is_frosted(), "an opaque bound pane leaves the DE recipe to the keys");
714         crate::color::set_finish_spec(0.4);
715         crate::color::set_finish_shininess(24.0);
716         crate::color::set_finish_curvature(0.2);
717         crate::color::reload_colors("");
718     }
719 
720     /// A popover is the base colour at menu opacity, frosted — the bytes the
721     /// three menu sites used to write by negating an alpha.
722     #[test]
723     fn popover_is_the_menu_recipe() {
724         let _lock = crate::color::test_color_state_lock();
725         let m = Material::popover([0.1, 0.2, 0.3, 1.0]);
726         let mut old = [0.1, 0.2, 0.3, 1.0];
727         old[3] = -crate::color::menu_opacity();
728         assert_eq!(m.fill(PlateRole::Nested), old);
729         assert!(m.frost.is_frosted());
730     }
731 
732     /// A popover's frost compresses at the menu key, not the DE recipe's:
733     /// the two are independent dials.
734     #[test]
735     fn popover_compresses_at_the_menu_key() {
736         let _lock = crate::color::test_color_state_lock();
737         crate::color::set_plate_backdrop_compression(0.1);
738         crate::color::set_menu_compression(0.7);
739         let m = Material::popover([0.1, 0.2, 0.3, 1.0]);
740         let Frost::Frosted { compression, .. } = m.frost else { panic!("popover is frosted") };
741         assert!((compression - 0.7).abs() < 1e-6, "popover compression {compression}");
742         let Frost::Frosted { compression: pane, .. } = Frost::from_style() else { panic!("recipe is frosted") };
743         assert!((pane - 0.1).abs() < 1e-6, "recipe compression {pane}");
744         crate::color::set_plate_backdrop_compression(0.0);
745         crate::color::set_menu_compression(0.6);
746     }
747 
748     /// The control-face rule: opaque or nothing.
749     #[test]
750     fn control_face_is_opaque_or_none() {
751         let face = Material::control_face([0.2, 0.3, 0.4, 0.5]).expect("a fill is a face");
752         assert_eq!(face.tint, [0.2, 0.3, 0.4, 1.0]);
753         assert_eq!(face.frost, Frost::Opaque);
754         assert!(Material::control_face([0.2, 0.3, 0.4, 0.0]).is_none());
755         assert_eq!(Material::control().frost, Frost::Opaque);
756         assert_eq!(Material::root().frost, Frost::Opaque);
757     }
758 }