Skip to main content

cranpose_liquid/
material.rs

1//! The Liquid Glass material: Gaussian backdrop frost followed by wcKSRD
2//! refraction applied to a composable's own bounds through
3//! [`LiquidModifierExt::glass_effect`] — the analogue of SwiftUI's
4//! `.glassEffect(_:in:)`.
5
6use std::{cell::Cell, rc::Rc};
7
8use cranpose_ui::{Modifier, current_density};
9use cranpose_ui_graphics::{
10    Color, GLASS_ACTIVITY_UNIFORM, GLASS_BLUR_RADIUS_UNIFORM, GLASS_DISPERSION_UNIFORM,
11    GLASS_EFFECT_DENSITY_UNIFORM, GLASS_FOLD_DEPTH_UNIFORM, GLASS_LIGHT_DIRECTION_UNIFORM,
12    GLASS_MENISCUS_ABSORPTION_UNIFORM, GLASS_OPTICAL_ZOOM_ANCHOR_UNIFORM,
13    GLASS_OPTICAL_ZOOM_UNIFORM, GLASS_REFRACTION_CURVE_UNIFORM, GLASS_RESTING_TINT_UNIFORM,
14    GLASS_TRANSMISSION_REFRACTION_UNIFORM, GraphicsLayer, LIQUID_GLASS_WGSL, LayerShape,
15    RenderEffect, RoundedCornerShape, RuntimeShader, TileMode,
16};
17
18use crate::theme::LiquidColors;
19
20// The ambient light environment shared by every glass material. The
21// reference material's bevel arcs rotate with the device: platforms feed
22// the current attitude here (Android: rotation sensor; desktop: the
23// default overhead light) and every glass surface re-lights on the next
24// frame — materials read it in their per-frame effect resolvers.
25//
26// The vector is the light's RETURN direction in screen space (where the
27// wide bright glow lands); the default (0, 1) is the reference's overhead
28// light with the return at the bottom rim.
29thread_local! {
30    static GLASS_LIGHT_RETURN: Cell<(f32, f32)> = const { Cell::new((0.0, 1.0)) };
31}
32
33/// Sets the ambient glass light return direction (screen space, need not be
34/// normalized — the shader normalizes). Feed device attitude here.
35pub fn set_glass_light_direction(direction: (f32, f32)) {
36    GLASS_LIGHT_RETURN.with(|cell| cell.set(direction));
37}
38
39/// The current ambient glass light return direction.
40pub fn glass_light_direction() -> (f32, f32) {
41    GLASS_LIGHT_RETURN.with(|cell| cell.get())
42}
43
44/// Corner radius large enough that [`cranpose_ui_graphics::CornerRadii::resolve`]
45/// clamps it to half the shape's size — i.e. a capsule.
46const CAPSULE_CLIP_RADIUS: f32 = 1.0e6;
47
48/// Shader sentinel requesting the capsule radius (resolved against the node's
49/// size at render time; see `liquid_glass.wgsl` cover mode).
50const CAPSULE_SHADER_RADIUS: f32 = -1.0;
51
52/// wcKSRD uses a tightly sampled 9x9 footprint (four half-pixel steps in
53/// either direction). Larger frost radii belong in the renderer's Gaussian
54/// pass; spreading those 81 taps over a large radius produces a visible grid.
55const WCKSRD_OPTICAL_BLUR_RADIUS_PX: f32 = 2.0;
56
57/// The shape of a glass element (also its clip and shadow shape).
58#[derive(Clone, Copy, Debug, PartialEq, Default)]
59pub enum LiquidShape {
60    /// A pill: corner radius follows the smaller half-extent.
61    #[default]
62    Capsule,
63    /// Rounded rectangle with the radius in dp.
64    RoundedRect(f32),
65    /// A circle (capsule of a square node).
66    Circle,
67}
68
69impl LiquidShape {
70    /// The clip shape handed to the graphics layer.
71    pub fn clip_shape(&self) -> RoundedCornerShape {
72        match self {
73            LiquidShape::Capsule | LiquidShape::Circle => {
74                RoundedCornerShape::uniform(CAPSULE_CLIP_RADIUS)
75            }
76            LiquidShape::RoundedRect(radius) => RoundedCornerShape::uniform(*radius),
77        }
78    }
79
80    /// The layer shape (clip + shadow geometry).
81    pub fn layer_shape(&self) -> LayerShape {
82        LayerShape::Rounded(self.clip_shape())
83    }
84
85    /// The radius uniform for the lens shader, in px (negative = capsule).
86    fn shader_radius_px(&self, density: f32) -> f32 {
87        match self {
88            LiquidShape::Capsule | LiquidShape::Circle => CAPSULE_SHADER_RADIUS,
89            LiquidShape::RoundedRect(radius) => radius * density,
90        }
91    }
92}
93
94/// Material variant, mirroring SwiftUI's `.regular` / `.clear` glass, plus
95/// the interactive lens.
96#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
97pub enum GlassVariant {
98    /// Frosted: strong backdrop blur, vibrancy boost, scheme-adaptive lift.
99    #[default]
100    Regular,
101    /// Transparent: minimal blur, mild vibrancy — for media-rich backdrops.
102    Clear,
103    /// The interactive magnifying bubble (drag lenses, flying selection):
104    /// no frost, no tone shift, a full-element dome and pronounced rainbow
105    /// dispersion at the rim.
106    Lens,
107}
108
109/// Shadow owned by a glass surface. Morphing glass evaluates the same live
110/// SDF for this shadow; clipped static glass forwards the values to the layer
111/// shadow primitive.
112#[derive(Clone, Copy, Debug, PartialEq)]
113pub struct GlassShadow {
114    pub color: Color,
115    pub radius: f32,
116    pub offset_y: f32,
117    pub spread: f32,
118}
119
120impl GlassShadow {
121    pub fn new(color: Color, radius: f32, offset_y: f32, spread: f32) -> Self {
122        Self {
123            color,
124            radius: radius.max(0.0),
125            offset_y,
126            spread,
127        }
128    }
129}
130
131/// Per-frame motion inputs for an interactive glass element, read lazily at
132/// scene-build time (no recomposition per frame).
133#[derive(Clone, Debug, PartialEq, Default)]
134pub struct GlassDynamics {
135    /// Continuous optical presence. `None` preserves the resolved material;
136    /// `Some(0)` is an exact backdrop identity while retaining the same node,
137    /// SDF, and pointer ride path.
138    pub activity: Option<f32>,
139    /// Base tint of the same persistent SDF surface at zero optical activity.
140    /// Its alpha cross-fades out as the refractive material rises.
141    pub resting_tint: Option<Color>,
142    /// Extra specular intensity (0 = spec default; e.g. press boost).
143    pub highlight_boost: f32,
144    /// Per-frame saturation added to the resolved material. Interactive
145    /// surfaces use this to raise chroma without changing their base tint.
146    pub saturation_boost: f32,
147    /// Optional per-frame multiplier for the material tint alpha. Values
148    /// below one clear a resting wash; values above one densify a raised tint.
149    pub tint_alpha_multiplier: Option<f32>,
150    /// Shape morph: when set, the glass geometry is these node-local rects
151    /// instead of the node cover — the shapeshift channel.
152    pub morph: Option<GlassMorph>,
153    /// Touch glow: `(x_dp, y_dp, intensity)` in node-local dp. A pressed
154    /// surface concentrates saturation and a soft light in a radial
155    /// gradient under the finger (never a flat recolor).
156    pub touch: Option<(f32, f32, f32)>,
157    /// Dome press depth: how squashed the interactive dome is. At 1 the
158    /// pressed dome refracts deep and vivid (wide rim band, strong
159    /// chromatic split — the reference toggle's gray-hold rainbow); toward
160    /// 0 the released bead relaxes shallow and its split fades with it
161    /// (the reference's thin settled ring). `None` = 1.
162    pub press_depth: Option<f32>,
163}
164
165impl GlassDynamics {
166    /// The universal touched-up state (user-directed law): a pressed
167    /// liquid surface comes closer to the user while its OWN colors go
168    /// HDR-bright and saturated, with a radial glow following the finger.
169    /// Never a repaint with alternate colors — the boosts amplify whatever
170    /// the surface already is. `press` is 0..1; `finger` is node-local dp
171    /// (falls back to `center`, the surface's own heart).
172    pub fn touched_up(
173        mut self,
174        press: f32,
175        finger: Option<(f32, f32)>,
176        center: (f32, f32),
177    ) -> Self {
178        let press = press.clamp(0.0, 1.0);
179        if press <= 0.0 {
180            return self;
181        }
182        self.highlight_boost += 0.85 * press;
183        self.saturation_boost += 0.45 * press;
184        let (fx, fy) = finger.unwrap_or(center);
185        let carried = self.touch.map(|(_, _, i)| i).unwrap_or(0.0);
186        self.touch = Some((fx, fy, (carried + press).clamp(0.0, 1.0)));
187        self
188    }
189}
190
191fn foreground_is_dark(foreground: Color) -> bool {
192    let foreground_luma =
193        0.2126 * foreground.r() + 0.7152 * foreground.g() + 0.0722 * foreground.b();
194    foreground_luma < 0.5
195}
196
197fn boost_tint_saturation(tint: Color, boost: f32) -> Color {
198    if boost.abs() <= f32::EPSILON {
199        return tint;
200    }
201    let luma = 0.2126 * tint.r() + 0.7152 * tint.g() + 0.0722 * tint.b();
202    let saturation = (1.0 + boost).max(0.0);
203    Color::rgba(
204        (luma + (tint.r() - luma) * saturation).clamp(0.0, 1.0),
205        (luma + (tint.g() - luma) * saturation).clamp(0.0, 1.0),
206        (luma + (tint.b() - luma) * saturation).clamp(0.0, 1.0),
207        tint.a(),
208    )
209}
210
211pub(crate) fn neutral_surface_tint(foreground: Color, light_alpha: f32, dark_alpha: f32) -> Color {
212    if foreground_is_dark(foreground) {
213        Color::BLACK.with_alpha(light_alpha.clamp(0.0, 1.0))
214    } else {
215        Color::WHITE.with_alpha(dark_alpha.clamp(0.0, 1.0))
216    }
217}
218
219pub(crate) fn neutral_surface_lift(foreground: Color, light_lift: f32, dark_lift: f32) -> f32 {
220    if foreground_is_dark(foreground) {
221        light_lift
222    } else {
223        dark_lift
224    }
225}
226
227/// A liquid shapeshift frame: the primary shape plus any number of nearby
228/// glass shapes, ALL smooth-unioned into one field — liquid glass glues to
229/// whatever glass it passes near (a growing menu necks with a neighboring
230/// button, the drag lens merges with the search circle). Up to
231/// [`GlassMorph::MAX_SHAPES`] extra shapes; an angular wobble makes the
232/// mid-flight field bubble like a droplet. All geometry is node-local dp:
233/// `(center_x, center_y, width, height, corner_radius)`; radius sentinel
234/// `-1` means capsule; `-2` means SUBTRACT capsule — the shape carves a
235/// smooth hole in the field (the growing menu leaves its anchor button
236/// crisp on top until it swallows it).
237#[derive(Clone, Debug, PartialEq, Default)]
238pub struct GlassMorph {
239    /// The glass node's own size in dp. The shader receives all morph
240    /// geometry in dp and derives px-per-dp from the renderer-injected node
241    /// pixel rect divided by this — geometry then lands correctly at ANY
242    /// render scale (live window density, robot captures at 1.0, fractional
243    /// desktop scales). The authoring widget always knows its node size.
244    pub node_size: (f32, f32),
245    pub primary: (f32, f32, f32, f32, f32),
246    /// Nearby glass shapes participating in the field.
247    pub shapes: Vec<(f32, f32, f32, f32, f32)>,
248    /// Smooth-union glue radius (dp): shapes within it neck together.
249    pub glue: f32,
250    /// Wobble amplitude (dp) and phase (radians).
251    pub wobble_amplitude: f32,
252    pub wobble_phase: f32,
253    /// Viscous leading-edge bulge: while the shape travels or inflates, its
254    /// side facing `bulge_direction` (radians, math convention) swells like a
255    /// pulled droplet. Amplitude in dp, usually driven by morph velocity.
256    pub bulge_amplitude: f32,
257    pub bulge_direction: f32,
258    /// Blends the primary rounded-rectangle field toward an ellipse. This is
259    /// used by expanding droplets whose broad phase has continuous curvature
260    /// rather than the straight sides of a capsule.
261    pub ellipse_blend: f32,
262    /// Area-preserving affine strain applied to the primary shape. Extra
263    /// scene shapes remain fixed so nearby glass can join the travelling
264    /// droplet without being dragged through its local deformation.
265    pub deformation: Option<GlassDeformation>,
266    /// Offset (dp) of the optical-zoom axis from the primary SDF center. A
267    /// leaning droplet's silhouette shifts toward its travel side while its
268    /// curvature apex stays over the content it rides; anchoring the
269    /// magnification there keeps the face filled by that content instead of
270    /// pulling in whatever lies beyond it.
271    pub zoom_anchor: (f32, f32),
272}
273
274/// A normalized motion axis and reciprocal scales for incompressible glass.
275/// Construction derives the cross-axis scale, so callers cannot describe a
276/// deformation that changes the bubble's area.
277#[derive(Clone, Copy, Debug, PartialEq)]
278pub struct GlassDeformation {
279    axis: (f32, f32),
280    along: f32,
281}
282
283impl GlassDeformation {
284    pub fn incompressible(axis: (f32, f32), along: f32) -> Self {
285        let length = (axis.0 * axis.0 + axis.1 * axis.1).sqrt();
286        let axis = if length > f32::EPSILON {
287            (axis.0 / length, axis.1 / length)
288        } else {
289            (1.0, 0.0)
290        };
291        Self {
292            axis,
293            along: along.max(f32::EPSILON),
294        }
295    }
296
297    pub fn axis(self) -> (f32, f32) {
298        self.axis
299    }
300
301    pub fn along(self) -> f32 {
302        self.along
303    }
304
305    pub fn across(self) -> f32 {
306        1.0 / self.along
307    }
308}
309
310impl GlassMorph {
311    /// Shader budget for extra scene shapes.
312    pub const MAX_SHAPES: usize = 8;
313}
314
315/// Builder describing a glass material. Resolved against the theme at the
316/// composition site, then evaluated per frame for density and dynamics.
317#[derive(Clone, Debug, PartialEq)]
318pub struct Glass {
319    pub variant: GlassVariant,
320    pub shape: LiquidShape,
321    /// Tint over the refracted backdrop; defaults to the theme's glass tint.
322    pub tint: Option<Color>,
323    /// Backdrop blur radius in dp (defaults per variant).
324    pub blur_radius: Option<f32>,
325    /// Saturation boost (defaults per variant).
326    pub saturation: Option<f32>,
327    /// wcKSRD refraction depth as a fraction of the shape inradius.
328    pub refraction_depth: f32,
329    /// Normalized wcKSRD ray-return exponent.
330    pub refraction_curve: f32,
331    /// Normalized wcKSRD spectral ray separation.
332    pub dispersion: f32,
333    /// Fraction of the wcKSRD displacement applied to the transmitted
334    /// backdrop. The mirrored meniscus follows its own optical path.
335    pub transmission_refraction: f32,
336    /// Energy removed from the transmitted ray at the meniscus. Reflection
337    /// and spectral return follow independent paths.
338    pub meniscus_absorption: f32,
339    /// Depth of the rim fold band in dp: the raised lens rim replays its
340    /// interior mirrored toward the edge (a pure displacement). Zero
341    /// disables the fold.
342    pub fold_depth: f32,
343    /// Uniform face magnification of a riding lens (1.0 = no zoom): the
344    /// backdrop projects enlarged across the whole face while the rim band
345    /// keeps the wcKSRD edge mapping.
346    pub optical_zoom: f32,
347    /// Meniscus rim reflectivity multiplier (1.0 = the reference toggle's
348    /// visible rim line; near 0 = the segmented lens's invisible body).
349    pub rim_reflection: f32,
350    /// Optical ink recolor: the lens recolors dark transmitted ink toward
351    /// this color at the given strength (the reference tab bubble's
352    /// color-mask act). None = off.
353    pub ink_recolor: Option<(Color, f32)>,
354    /// Specular rim intensity.
355    pub highlight: f32,
356    /// Screen-lift override (brightening toward white; negative darkens).
357    /// Defaults per variant.
358    pub lift: Option<f32>,
359    /// Tone-compression override around the shader's mid pivot (<1 pulls
360    /// every backdrop toward mid-luminance — the reference dark menu reads
361    /// bright magenta over deep purple AND dim gray over white through one
362    /// law). Defaults per variant.
363    pub contrast: Option<f32>,
364    /// Drop shadow below the glass.
365    pub shadow: bool,
366    /// Per-surface shadow override.
367    pub shadow_style: Option<GlassShadow>,
368    /// Clip the layer to `shape`. Morphing glass disables this — coverage
369    /// comes entirely from the shader's SDF.
370    pub clip: bool,
371    /// Foreground color whose contrast the frost must protect. `None` uses
372    /// the theme label color.
373    pub foreground: Option<Color>,
374    /// Strength of backdrop+foreground frost adaptation.
375    pub adaptive_frost: f32,
376}
377
378impl Glass {
379    pub fn regular() -> Self {
380        Self {
381            variant: GlassVariant::Regular,
382            shape: LiquidShape::Capsule,
383            tint: None,
384            blur_radius: None,
385            saturation: None,
386            refraction_depth: 0.34,
387            refraction_curve: 0.25,
388            dispersion: 0.0,
389            transmission_refraction: 1.0,
390            meniscus_absorption: 1.0,
391            fold_depth: 0.0,
392            optical_zoom: 1.0,
393            rim_reflection: 1.0,
394            ink_recolor: None,
395            highlight: 0.9,
396            lift: None,
397            contrast: None,
398            shadow: true,
399            shadow_style: None,
400            clip: true,
401            foreground: None,
402            adaptive_frost: 0.65,
403        }
404    }
405
406    pub fn clear() -> Self {
407        Self {
408            variant: GlassVariant::Clear,
409            ..Self::regular()
410        }
411    }
412
413    /// The interactive lens bubble (reference: the iOS toggle/tab-bar drag
414    /// lens): fully transparent, magnifying dome across the whole element,
415    /// strong rainbow rim.
416    pub fn lens() -> Self {
417        Self {
418            variant: GlassVariant::Lens,
419            shape: LiquidShape::Capsule,
420            tint: Some(Color::rgba(1.0, 1.0, 1.0, 0.07)),
421            blur_radius: None,
422            saturation: None,
423            refraction_depth: 0.34,
424            refraction_curve: 1.0,
425            dispersion: 0.30,
426            transmission_refraction: 1.0,
427            meniscus_absorption: 1.0,
428            fold_depth: 0.0,
429            optical_zoom: 1.0,
430            rim_reflection: 1.0,
431            ink_recolor: None,
432            highlight: 1.15,
433            lift: None,
434            contrast: None,
435            shadow: true,
436            shadow_style: None,
437            clip: true,
438            foreground: None,
439            adaptive_frost: 0.0,
440        }
441    }
442
443    pub fn shape(mut self, shape: LiquidShape) -> Self {
444        self.shape = shape;
445        self
446    }
447
448    pub fn tint(mut self, tint: Color) -> Self {
449        self.tint = Some(tint);
450        self
451    }
452
453    pub fn blur_radius(mut self, radius_dp: f32) -> Self {
454        self.blur_radius = Some(radius_dp);
455        self
456    }
457
458    pub fn saturation(mut self, saturation: f32) -> Self {
459        self.saturation = Some(saturation);
460        self
461    }
462
463    pub fn refraction_depth(mut self, fraction: f32) -> Self {
464        self.refraction_depth = fraction.clamp(0.0, 2.0);
465        self
466    }
467
468    pub fn refraction_curve(mut self, curve: f32) -> Self {
469        self.refraction_curve = curve.clamp(0.05, 1.0);
470        self
471    }
472
473    pub fn dispersion(mut self, strength: f32) -> Self {
474        self.dispersion = strength.clamp(0.0, 1.0);
475        self
476    }
477
478    pub fn transmission_refraction(mut self, strength: f32) -> Self {
479        self.transmission_refraction = strength.clamp(0.0, 1.0);
480        self
481    }
482
483    pub fn meniscus_absorption(mut self, strength: f32) -> Self {
484        self.meniscus_absorption = strength.clamp(0.0, 1.0);
485        self
486    }
487
488    /// Sets the rim fold band depth in dp (zero disables the fold).
489    pub fn fold_depth(mut self, depth_dp: f32) -> Self {
490        self.fold_depth = depth_dp.max(0.0);
491        self
492    }
493
494    /// Sets the uniform face magnification of a riding lens (1.0 = none).
495    pub fn optical_zoom(mut self, zoom: f32) -> Self {
496        self.optical_zoom = zoom.max(1.0);
497        self
498    }
499
500    /// Sets the meniscus rim reflectivity (1.0 = full reference line).
501    pub fn rim_reflection(mut self, reflectivity: f32) -> Self {
502        self.rim_reflection = reflectivity.clamp(0.0, 2.0);
503        self
504    }
505
506    /// The lens recolors dark transmitted ink toward `color` at `strength`
507    /// (0..1) — the reference bubble's color-mask act as a material optic.
508    pub fn ink_recolor(mut self, color: Color, strength: f32) -> Self {
509        self.ink_recolor = Some((color, strength.clamp(0.0, 1.0)));
510        self
511    }
512
513    pub fn highlight(mut self, highlight: f32) -> Self {
514        self.highlight = highlight;
515        self
516    }
517
518    /// Overrides the screen-lift (how hard the glass brightens what it
519    /// shows; the bar lens uses a near-zero lift to stay transmissive).
520    pub fn lift(mut self, lift: f32) -> Self {
521        self.lift = Some(lift);
522        self
523    }
524
525    /// Overrides the tone compression around the shader's mid pivot
526    /// (values below one pull every backdrop toward mid-luminance).
527    pub fn contrast(mut self, contrast: f32) -> Self {
528        self.contrast = Some(contrast.max(0.05));
529        self
530    }
531
532    pub fn adaptive_frost(mut self, foreground: Color, strength: f32) -> Self {
533        self.foreground = Some(foreground);
534        self.adaptive_frost = strength.clamp(0.0, 1.0);
535        self
536    }
537
538    pub fn shadow(mut self, shadow: bool) -> Self {
539        self.shadow = shadow;
540        self
541    }
542
543    pub fn shadow_style(mut self, shadow: GlassShadow) -> Self {
544        self.shadow_style = Some(shadow);
545        self
546    }
547
548    /// Disables the layer clip: the shader's SDF coverage is the only shape
549    /// (required while morphing across geometry the clip can't follow).
550    pub fn no_clip(mut self) -> Self {
551        self.clip = false;
552        self
553    }
554
555    fn default_blur_radius(&self) -> f32 {
556        match self.variant {
557            GlassVariant::Regular => 8.0,
558            GlassVariant::Clear => 3.0,
559            GlassVariant::Lens => 0.0,
560        }
561    }
562
563    fn default_saturation(&self) -> f32 {
564        match self.variant {
565            GlassVariant::Regular => 1.5,
566            GlassVariant::Clear => 1.25,
567            GlassVariant::Lens => 1.0,
568        }
569    }
570
571    /// Theme-resolved material constants captured at composition time.
572    pub(crate) fn resolve(&self, colors: &LiquidColors) -> ResolvedGlass {
573        // Screen-lift keeps the blurred backdrop's colors alive while reading
574        // bright (the reference material is far whiter than an alpha tint
575        // could get without going milky).
576        // The reference menu/bar glass shows blurred content smudges through
577        // its body — lift bright but never opaque; the lens lightens what it
578        // magnifies noticeably (the pressed toggle's green reads lifted).
579        let lift = self.lift.unwrap_or(match (self.variant, colors.is_dark) {
580            (GlassVariant::Regular, false) => 0.42,
581            (GlassVariant::Regular, true) => -0.38,
582            (GlassVariant::Clear, false) => 0.12,
583            (GlassVariant::Clear, true) => -0.12,
584            (GlassVariant::Lens, false) => 0.10,
585            (GlassVariant::Lens, true) => -0.08,
586        });
587        let foreground = self.foreground.unwrap_or(colors.label);
588        let shadow = self.shadow_style.unwrap_or_else(|| {
589            GlassShadow::new(
590                Color::BLACK.with_alpha(match (self.variant, colors.is_dark) {
591                    (GlassVariant::Lens, false) => 0.14,
592                    (GlassVariant::Lens, true) => 0.28,
593                    (_, false) => 0.16,
594                    (_, true) => 0.5,
595                }),
596                if self.variant == GlassVariant::Lens {
597                    10.0
598                } else {
599                    22.0
600                },
601                if self.variant == GlassVariant::Lens {
602                    3.0
603                } else {
604                    8.0
605                },
606                if self.variant == GlassVariant::Lens {
607                    -6.0
608                } else {
609                    -2.0
610                },
611            )
612        });
613        ResolvedGlass {
614            shape: self.shape,
615            tint: self.tint.unwrap_or(colors.glass_tint),
616            blur_radius_dp: self
617                .blur_radius
618                .unwrap_or_else(|| self.default_blur_radius()),
619            saturation: self.saturation.unwrap_or_else(|| self.default_saturation()),
620            refraction_depth: self.refraction_depth,
621            refraction_curve: self.refraction_curve,
622            dispersion: self.dispersion,
623            transmission_refraction: self.transmission_refraction,
624            meniscus_absorption: self.meniscus_absorption,
625            fold_depth: self.fold_depth,
626            optical_zoom: self.optical_zoom,
627            rim_reflection: self.rim_reflection,
628            ink_recolor: self.ink_recolor,
629            highlight: self.highlight,
630            lift,
631            contrast: self.contrast.unwrap_or(match self.variant {
632                GlassVariant::Lens => 1.0,
633                _ => 1.03,
634            }),
635            shadow: self.shadow,
636            clip: self.clip,
637            foreground_luma: 0.2126 * foreground.r()
638                + 0.7152 * foreground.g()
639                + 0.0722 * foreground.b(),
640            adaptive_frost: self.adaptive_frost,
641            rim_style: if self.variant == GlassVariant::Lens {
642                1.0
643            } else {
644                0.0
645            },
646            shadow_color: shadow.color,
647            shadow_radius: shadow.radius,
648            shadow_offset_y: shadow.offset_y,
649            shadow_spread: shadow.spread,
650        }
651    }
652}
653
654impl Default for Glass {
655    fn default() -> Self {
656        Self::regular()
657    }
658}
659
660/// A theme-resolved glass material; density and dynamics are applied per
661/// frame in the lazy graphics-layer resolver.
662#[derive(Clone, Debug, PartialEq)]
663pub(crate) struct ResolvedGlass {
664    pub shape: LiquidShape,
665    pub tint: Color,
666    pub blur_radius_dp: f32,
667    pub saturation: f32,
668    pub refraction_depth: f32,
669    pub refraction_curve: f32,
670    pub dispersion: f32,
671    pub transmission_refraction: f32,
672    pub meniscus_absorption: f32,
673    pub fold_depth: f32,
674    pub optical_zoom: f32,
675    pub rim_reflection: f32,
676    pub ink_recolor: Option<(Color, f32)>,
677    pub highlight: f32,
678    pub lift: f32,
679    pub contrast: f32,
680    pub shadow: bool,
681    pub clip: bool,
682    /// 0 = surface glass (soft white spec rim); 1 = interactive lens (thin
683    /// bright line + stronger dark outline, chroma does the color).
684    pub rim_style: f32,
685    pub foreground_luma: f32,
686    pub adaptive_frost: f32,
687    pub shadow_color: Color,
688    /// Variant-scaled drop shadow geometry: the lens bubble carries a tight
689    /// contact hint, large surfaces a soft wide ambient.
690    pub shadow_radius: f32,
691    pub shadow_offset_y: f32,
692    pub shadow_spread: f32,
693}
694
695impl ResolvedGlass {
696    /// Builds the wcKSRD shader for the current density and
697    /// per-frame dynamics. Cover mode keeps geometry in pixels with the
698    /// container uniform zeroed; the shader owns all backdrop samples.
699    pub(crate) fn backdrop_effect(&self, density: f32, dynamics: GlassDynamics) -> RenderEffect {
700        self.runtime_effect(density, dynamics, false)
701    }
702
703    fn content_mask_effect(&self, density: f32, dynamics: GlassDynamics) -> RenderEffect {
704        self.runtime_effect(density, dynamics, true)
705    }
706
707    fn runtime_effect(
708        &self,
709        density: f32,
710        dynamics: GlassDynamics,
711        content_mask: bool,
712    ) -> RenderEffect {
713        let density = density.max(f32::EPSILON);
714        let activity = dynamics
715            .activity
716            .filter(|value| value.is_finite())
717            .unwrap_or(1.0)
718            .clamp(0.0, 1.0);
719        let mut shader = RuntimeShader::new(LIQUID_GLASS_WGSL);
720        if let Some(morph) = dynamics.morph.as_ref() {
721            // Morph glass: the container carries the node size in dp and ALL
722            // geometry is dp — the shader divides the renderer-injected node
723            // pixel rect by the container, so the field lands correctly at
724            // any render scale (density-scaled packing broke every capture
725            // whose render scale differed from the platform density).
726            let (node_w, node_h) = morph.node_size;
727            let (cx, cy, w, h, radius) = morph.primary;
728            shader.set_float2(0, node_w.max(1.0), node_h.max(1.0));
729            shader.set_float2(2, cx, cy);
730            shader.set_float2(4, w, h);
731            shader.set_float(6, radius);
732            let count = morph.shapes.len().min(GlassMorph::MAX_SHAPES);
733            shader.set_float(30, count as f32);
734            for (index, (sx, sy, sw, sh, sr)) in morph.shapes.iter().take(count).enumerate() {
735                let base = 36 + index * 5;
736                shader.set_float(base, *sx);
737                shader.set_float(base + 1, *sy);
738                shader.set_float(base + 2, *sw);
739                shader.set_float(base + 3, *sh);
740                shader.set_float(base + 4, *sr);
741            }
742            shader.set_float(31, morph.glue);
743            shader.set_float(32, morph.wobble_amplitude * activity);
744            shader.set_float(33, morph.wobble_phase);
745            shader.set_float(26, morph.bulge_amplitude * activity);
746            shader.set_float(27, morph.bulge_direction);
747            shader.set_float(110, morph.ellipse_blend.clamp(0.0, 1.0) * activity);
748            if let Some(deformation) = morph.deformation {
749                let axis = deformation.axis();
750                let along = 1.0 + (deformation.along() - 1.0) * activity;
751                shader.set_float2(106, axis.0, axis.1);
752                shader.set_float(108, along);
753                shader.set_float(109, 1.0 / along);
754            } else {
755                shader.set_float2(106, 1.0, 0.0);
756                shader.set_float2(108, 1.0, 1.0);
757            }
758            // Bezel in dp: the shader scales by px-per-dp.
759        } else {
760            // Cover mode marker: container size stays zero. Geometry is px at
761            // the platform density (node size only known at render time).
762            shader.set_float2(0, 0.0, 0.0);
763            shader.set_float(6, self.shape.shader_radius_px(density));
764        }
765        let press_depth = dynamics.press_depth.unwrap_or(1.0).clamp(0.0, 1.0);
766        shader.set_float(9, self.refraction_depth * activity * press_depth);
767        shader.set_float(
768            GLASS_REFRACTION_CURVE_UNIFORM,
769            self.refraction_curve * activity,
770        );
771        shader.set_float(
772            GLASS_DISPERSION_UNIFORM,
773            self.dispersion * activity * press_depth,
774        );
775        shader.set_float(
776            GLASS_TRANSMISSION_REFRACTION_UNIFORM,
777            self.transmission_refraction * activity,
778        );
779        // Meniscus energy rides the dome depth like the fold: a shallow
780        // settled bead keeps only a whisper of its rim absorption.
781        shader.set_float(
782            GLASS_MENISCUS_ABSORPTION_UNIFORM,
783            self.meniscus_absorption * press_depth,
784        );
785        // Always write optional channels — a conditional write leaves stale
786        // values behind if a shader instance is ever pooled or reused, and
787        // that leak class renders one surface with another's optics.
788        // The fold band is part of the dome: a shallow bead folds shallow,
789        // so the rim band rides the same press-depth as the refraction.
790        shader.set_float(
791            GLASS_FOLD_DEPTH_UNIFORM,
792            self.fold_depth.max(0.0) * press_depth,
793        );
794        shader.set_float(
795            GLASS_OPTICAL_ZOOM_UNIFORM,
796            1.0 + (self.optical_zoom - 1.0).max(0.0) * activity,
797        );
798        let zoom_anchor = dynamics
799            .morph
800            .as_ref()
801            .map(|morph| morph.zoom_anchor)
802            .unwrap_or((0.0, 0.0));
803        shader.set_float2(
804            GLASS_OPTICAL_ZOOM_ANCHOR_UNIFORM,
805            zoom_anchor.0,
806            zoom_anchor.1,
807        );
808        shader.set_float(121, self.rim_reflection.max(0.001));
809        let (ink_color, ink_strength) = self
810            .ink_recolor
811            .map(|(color, strength)| (color, strength * activity))
812            .unwrap_or((Color::TRANSPARENT, 0.0));
813        shader.set_float(124, ink_color.r());
814        shader.set_float(125, ink_color.g());
815        shader.set_float(126, ink_color.b());
816        shader.set_float(127, ink_strength);
817        let (light_x, light_y) = glass_light_direction();
818        shader.set_float(GLASS_LIGHT_DIRECTION_UNIFORM, light_x);
819        shader.set_float(GLASS_LIGHT_DIRECTION_UNIFORM + 1, light_y);
820        let (touch_x, touch_y, touch_intensity) = dynamics.touch.unwrap_or((0.0, 0.0, 0.0));
821        shader.set_float(118, touch_x);
822        shader.set_float(119, touch_y);
823        shader.set_float(120, touch_intensity.clamp(0.0, 1.0));
824        shader.set_float(GLASS_EFFECT_DENSITY_UNIFORM, density);
825        shader.set_float(
826            11,
827            (self.highlight + dynamics.highlight_boost).clamp(0.0, 2.0) * activity,
828        );
829        let dynamic_tint = boost_tint_saturation(self.tint, dynamics.saturation_boost);
830        let dynamic_tint_alpha = (dynamic_tint.a()
831            * dynamics
832                .tint_alpha_multiplier
833                .unwrap_or(1.0)
834                .clamp(0.0, 2.0)
835            * activity)
836            .clamp(0.0, 1.0);
837        shader.set_float4(
838            14,
839            dynamic_tint.r(),
840            dynamic_tint.g(),
841            dynamic_tint.b(),
842            dynamic_tint_alpha,
843        );
844        let saturation = (self.saturation + dynamics.saturation_boost).max(0.0);
845        shader.set_float(18, 1.0 + (saturation - 1.0) * activity);
846        shader.set_float(20, self.lift * activity);
847        shader.set_float(21, 0.5 * activity);
848        shader.set_float2(22, 0.0, 1.0);
849        shader.set_float(24, 1.0 + (self.contrast - 1.0) * activity);
850        shader.set_float(28, self.rim_style * activity);
851        let requested_blur_radius_px = if content_mask {
852            0.0
853        } else {
854            self.blur_radius_dp * density * activity
855        };
856        // A blur past the optical cap rides the separable Gaussian pass
857        // ENTIRELY. Stacking the capped optical tap on top of a Gaussian
858        // that is already 3-10x wider moves the effective radius by under a
859        // percent (sigmas add in quadrature) while costing a 25-tap loop on
860        // every glass pixel of every frame — fixed glass over a scrolling
861        // list re-runs it per frame with no cache to hide behind.
862        let (wcksrd_blur_radius, gaussian_blur_radius) =
863            if requested_blur_radius_px > WCKSRD_OPTICAL_BLUR_RADIUS_PX {
864                (0.0, requested_blur_radius_px)
865            } else {
866                (requested_blur_radius_px, 0.0)
867            };
868        shader.set_float(GLASS_BLUR_RADIUS_UNIFORM, wcksrd_blur_radius);
869        shader.set_float(GLASS_ACTIVITY_UNIFORM, activity);
870        let resting_tint = dynamics.resting_tint.unwrap_or(Color::TRANSPARENT);
871        shader.set_float4(
872            GLASS_RESTING_TINT_UNIFORM,
873            resting_tint.r(),
874            resting_tint.g(),
875            resting_tint.b(),
876            resting_tint.a(),
877        );
878        shader.set_float(112, if content_mask { 1.0 } else { 0.0 });
879        shader.set_float(91, self.adaptive_frost * activity);
880        shader.set_float(97, self.foreground_luma);
881        let dynamic_shadow = !self.clip && self.shadow;
882        shader.set_float(
883            102,
884            if dynamic_shadow {
885                self.shadow_color.a() * 0.55 * activity
886            } else {
887                0.0
888            },
889        );
890        shader.set_float(103, self.shadow_radius);
891        shader.set_float(104, self.shadow_offset_y);
892        shader.set_float(105, self.shadow_spread);
893        // Morph padding: wobble reach plus how far any scene shape (plus its
894        // glue neck) extends beyond the primary rect — the capture and the
895        // composite surface must cover the whole glued field.
896        let morph_pad = dynamics
897            .morph
898            .as_ref()
899            .map(|morph| {
900                let (px, py, pw, ph, _) = morph.primary;
901                let (left, top) = (px - pw * 0.5, py - ph * 0.5);
902                let (right, bottom) = (px + pw * 0.5, py + ph * 0.5);
903                let mut shape_reach = 0.0f32;
904                for (sx, sy, sw, sh, _) in &morph.shapes {
905                    let reach_x = ((sx + sw * 0.5) - right)
906                        .max(left - (sx - sw * 0.5))
907                        .max(0.0);
908                    let reach_y = ((sy + sh * 0.5) - bottom)
909                        .max(top - (sy - sh * 0.5))
910                        .max(0.0);
911                    shape_reach = shape_reach.max(reach_x.max(reach_y));
912                }
913                let glue_pad = if morph.shapes.is_empty() {
914                    0.0
915                } else {
916                    morph.glue * 2.0
917                };
918                morph.wobble_amplitude * 2.0 + morph.bulge_amplitude + shape_reach + glue_pad
919            })
920            .unwrap_or(0.0);
921        // Paddings are consumed in LOGICAL units by the backdrop capture and
922        // output rects — dp, never density-scaled.
923        shader.set_input_padding(self.input_padding() + morph_pad + wcksrd_blur_radius / density);
924        // Morphing glass WRITES outside the node rect (wobble, bulge, glued
925        // neighbors, plus the ~2px antialiased rim); declare it so the
926        // composite scissor doesn't clip the field at the node edge.
927        if dynamics.morph.is_some() {
928            let shadow_reach = if dynamic_shadow {
929                self.shadow_radius + self.shadow_offset_y.abs() + self.shadow_spread.max(0.0)
930            } else {
931                0.0
932            };
933            shader.set_output_padding(morph_pad + shadow_reach + 4.0);
934        }
935
936        let optical_effect = RenderEffect::runtime_shader(shader);
937        if gaussian_blur_radius > f32::EPSILON {
938            // Mirror at the capture boundary: a backdrop capture is clipped
939            // at the surface's own edge (a top nav band's capture cannot
940            // extend above the page), and clamp-to-edge there stretches a
941            // single jittering content row across half the kernel — the
942            // band's top pixels pulse ~12 gray levels per scroll step
943            // (measured live). Mirroring keeps the edge statistics stable.
944            RenderEffect::blur_with_edge_treatment(gaussian_blur_radius, TileMode::Mirror)
945                .then(optical_effect)
946        } else {
947            optical_effect
948        }
949    }
950
951    /// Backdrop capture padding (px) covering the largest refracted sample
952    /// (see `liquid_glass_input_padding` for the explicit-rect twin). Padded
953    /// for tilt up to ±1 per axis so per-frame tilt never outruns the capture.
954    fn input_padding(&self) -> f32 {
955        // wcKSRD maps every refracted coordinate toward the center of the
956        // already-captured backdrop. Only its minimum 9x9 sample footprint
957        // extends beyond that segment; blur and morph reach are added by the
958        // caller from their actual runtime values.
959        2.0
960    }
961}
962
963/// Modifier extension installing the Liquid Glass material.
964pub trait LiquidModifierExt {
965    /// Applies the glass material to this composable's bounds: backdrop blur +
966    /// lens shader, clipped to `glass.shape`, with a soft drop shadow.
967    ///
968    /// Must be called in composable context (the material resolves theme
969    /// colors at the call site).
970    fn glass_effect(self, glass: Glass) -> Modifier;
971
972    /// [`glass_effect`](Self::glass_effect) with per-frame motion inputs; the
973    /// closure is read at scene-build time, so animating tilt or highlight
974    /// does not recompose.
975    fn glass_effect_with(
976        self,
977        glass: Glass,
978        dynamics: impl Fn() -> GlassDynamics + 'static,
979    ) -> Modifier;
980}
981
982impl LiquidModifierExt for Modifier {
983    fn glass_effect(self, glass: Glass) -> Modifier {
984        self.glass_effect_with(glass, GlassDynamics::default)
985    }
986
987    fn glass_effect_with(
988        self,
989        glass: Glass,
990        dynamics: impl Fn() -> GlassDynamics + 'static,
991    ) -> Modifier {
992        let colors = crate::theme::liquid_colors();
993        let resolved = Rc::new(glass.resolve(&colors));
994        let shape = resolved.shape;
995
996        let mut modifier = self;
997        if resolved.shadow && resolved.clip {
998            let shadow_color = resolved.shadow_color;
999            let (radius, offset_y, spread) = (
1000                resolved.shadow_radius,
1001                resolved.shadow_offset_y,
1002                resolved.shadow_spread,
1003            );
1004            modifier = modifier.drop_shadow(shape.layer_shape(), move |scope| {
1005                scope.radius = radius;
1006                scope.spread = spread;
1007                scope.offset.y = offset_y;
1008                scope.color = shadow_color;
1009                // Glass samples the backdrop behind itself — knock the shape
1010                // out of its own shadow so the material stays bright.
1011                scope.cutout = true;
1012            });
1013        }
1014
1015        let layer_resolved = Rc::clone(&resolved);
1016        let clip = resolved.clip;
1017        modifier.graphics_layer(move || {
1018            let density = current_density();
1019            let frame = dynamics();
1020            let render_effect = (!clip && frame.morph.is_some())
1021                .then(|| layer_resolved.content_mask_effect(density, frame.clone()));
1022            GraphicsLayer {
1023                backdrop_effect: Some(layer_resolved.backdrop_effect(density, frame)),
1024                render_effect,
1025                shape: shape.layer_shape(),
1026                clip,
1027                ..Default::default()
1028            }
1029        })
1030    }
1031}
1032
1033#[cfg(test)]
1034mod tests {
1035    use super::*;
1036
1037    fn light_colors() -> LiquidColors {
1038        LiquidColors::light(Color::from_rgb_u8(0, 122, 255))
1039    }
1040
1041    fn terminal_shader(effect: RenderEffect) -> RuntimeShader {
1042        match effect {
1043            RenderEffect::Shader { shader } => shader,
1044            RenderEffect::Chain { second, .. } => terminal_shader(*second),
1045            effect => panic!("expected runtime shader, got {effect:?}"),
1046        }
1047    }
1048
1049    #[test]
1050    fn glass_light_direction_defaults_overhead_and_reaches_the_shader() {
1051        assert_eq!(glass_light_direction(), (0.0, 1.0));
1052        let resolved = Glass::regular().resolve(&light_colors());
1053        let effect = resolved.backdrop_effect(2.0, GlassDynamics::default());
1054        let shader = terminal_shader(effect);
1055        let u = shader.uniforms();
1056        assert_eq!(u[GLASS_LIGHT_DIRECTION_UNIFORM], 0.0);
1057        assert_eq!(u[GLASS_LIGHT_DIRECTION_UNIFORM + 1], 1.0);
1058
1059        // Rotating the environment (device attitude) re-lights the next
1060        // resolved effect.
1061        set_glass_light_direction((1.0, 0.0));
1062        let effect = resolved.backdrop_effect(2.0, GlassDynamics::default());
1063        let u_rotated = terminal_shader(effect);
1064        let u_rotated = u_rotated.uniforms();
1065        assert_eq!(u_rotated[GLASS_LIGHT_DIRECTION_UNIFORM], 1.0);
1066        assert_eq!(u_rotated[GLASS_LIGHT_DIRECTION_UNIFORM + 1], 0.0);
1067        set_glass_light_direction((0.0, 1.0));
1068    }
1069
1070    #[test]
1071    fn liquid_shape_builds_matching_clip_and_layer_shapes() {
1072        for shape in [
1073            LiquidShape::Capsule,
1074            LiquidShape::Circle,
1075            LiquidShape::RoundedRect(12.0),
1076        ] {
1077            assert_eq!(shape.layer_shape(), LayerShape::Rounded(shape.clip_shape()));
1078        }
1079    }
1080
1081    #[test]
1082    fn glass_shadow_clamps_negative_radius() {
1083        let shadow = GlassShadow::new(Color::BLACK, -2.0, 3.0, -1.0);
1084        assert_eq!(shadow.radius, 0.0);
1085        assert_eq!(shadow.offset_y, 3.0);
1086        assert_eq!(shadow.spread, -1.0);
1087    }
1088
1089    #[test]
1090    fn incompressible_deformation_normalizes_axis_and_conserves_area() {
1091        let deformation = GlassDeformation::incompressible((3.0, 4.0), 1.25);
1092        assert_eq!(deformation.axis(), (0.6, 0.8));
1093        assert_eq!(deformation.along(), 1.25);
1094        assert!((deformation.along() * deformation.across() - 1.0).abs() < 1.0e-6);
1095        assert_eq!(
1096            GlassDeformation::incompressible((0.0, 0.0), 0.0).axis(),
1097            (1.0, 0.0)
1098        );
1099    }
1100
1101    #[test]
1102    fn glass_builders_clamp_physical_inputs() {
1103        let glass = Glass::lens()
1104            .shape(LiquidShape::Circle)
1105            .tint(Color::BLACK)
1106            .blur_radius(-2.0)
1107            .saturation(1.2)
1108            .refraction_depth(3.0)
1109            .refraction_curve(2.0)
1110            .dispersion(2.0)
1111            .transmission_refraction(2.0)
1112            .meniscus_absorption(2.0)
1113            .highlight(0.4)
1114            .lift(-0.2)
1115            .adaptive_frost(Color::WHITE, 2.0)
1116            .shadow(false)
1117            .no_clip();
1118        assert_eq!(glass.shape, LiquidShape::Circle);
1119        assert_eq!(glass.tint, Some(Color::BLACK));
1120        assert_eq!(glass.blur_radius, Some(-2.0));
1121        assert_eq!(glass.saturation, Some(1.2));
1122        assert_eq!(glass.refraction_depth, 2.0);
1123        assert_eq!(glass.refraction_curve, 1.0);
1124        assert_eq!(glass.dispersion, 1.0);
1125        assert_eq!(glass.transmission_refraction, 1.0);
1126        assert_eq!(glass.meniscus_absorption, 1.0);
1127        assert_eq!(glass.highlight, 0.4);
1128        assert_eq!(glass.lift, Some(-0.2));
1129        assert_eq!(glass.adaptive_frost, 1.0);
1130        assert!(!glass.shadow);
1131        assert!(!glass.clip);
1132    }
1133
1134    #[test]
1135    fn material_variants_resolve_distinct_frost_levels() {
1136        let regular = Glass::regular().resolve(&light_colors());
1137        let clear = Glass::clear().resolve(&light_colors());
1138        let lens = Glass::lens().resolve(&light_colors());
1139        assert!(regular.blur_radius_dp > clear.blur_radius_dp);
1140        assert!(clear.blur_radius_dp > lens.blur_radius_dp);
1141        assert!(regular.saturation > clear.saturation);
1142        assert_eq!(lens.rim_style, 1.0);
1143        assert_eq!(regular.refraction_curve, 0.25);
1144        assert_eq!(lens.refraction_curve, 1.0);
1145        assert_eq!(regular.dispersion, 0.0);
1146        assert_eq!(lens.dispersion, 0.30);
1147    }
1148
1149    #[test]
1150    fn neutral_surface_helpers_follow_foreground_polarity() {
1151        assert_eq!(
1152            neutral_surface_tint(Color::BLACK, 0.08, 0.10),
1153            Color::BLACK.with_alpha(0.08)
1154        );
1155        assert_eq!(
1156            neutral_surface_tint(Color::WHITE, 0.08, 0.10),
1157            Color::WHITE.with_alpha(0.10)
1158        );
1159        assert_eq!(neutral_surface_lift(Color::BLACK, 0.7, -0.3), 0.7);
1160        assert_eq!(neutral_surface_lift(Color::WHITE, 0.7, -0.3), -0.3);
1161    }
1162
1163    #[test]
1164    fn dynamic_saturation_reaches_the_material_tint() {
1165        let resting = Color::from_rgb_u8(0, 199, 208);
1166        let raised = boost_tint_saturation(resting, 0.55);
1167        assert_eq!(raised.r(), 0.0);
1168        assert!(raised.g() > resting.g());
1169        assert!(raised.b() > resting.b());
1170        assert_eq!(raised.a(), resting.a());
1171    }
1172
1173    #[test]
1174    fn resolved_material_packs_wcksrd_and_dynamic_tint() {
1175        let resolved = Glass::lens()
1176            .refraction_depth(0.72)
1177            .refraction_curve(0.8)
1178            .dispersion(0.42)
1179            .transmission_refraction(0.35)
1180            .meniscus_absorption(0.3)
1181            .blur_radius(3.0)
1182            .tint(Color::BLACK.with_alpha(0.8))
1183            .resolve(&light_colors());
1184        let effect = resolved.backdrop_effect(
1185            2.0,
1186            GlassDynamics {
1187                highlight_boost: 0.2,
1188                saturation_boost: 0.35,
1189                tint_alpha_multiplier: Some(0.25),
1190                ..Default::default()
1191            },
1192        );
1193        let RenderEffect::Chain { first, .. } = &effect else {
1194            panic!("macroscopic frost must precede the wcKSRD optical pass");
1195        };
1196        assert!(matches!(
1197            first.as_ref(),
1198            RenderEffect::Blur {
1199                radius_x: 6.0,
1200                radius_y: 6.0,
1201                ..
1202            }
1203        ));
1204        let shader = terminal_shader(effect);
1205        assert_eq!(shader.uniforms()[9], 0.72);
1206        assert_eq!(shader.uniforms()[GLASS_REFRACTION_CURVE_UNIFORM], 0.8);
1207        assert_eq!(shader.uniforms()[GLASS_DISPERSION_UNIFORM], 0.42);
1208        assert_eq!(
1209            shader.uniforms()[GLASS_TRANSMISSION_REFRACTION_UNIFORM],
1210            0.35
1211        );
1212        assert_eq!(shader.uniforms()[GLASS_MENISCUS_ABSORPTION_UNIFORM], 0.3);
1213        assert_eq!(shader.uniforms()[11], resolved.highlight + 0.2);
1214        assert_eq!(shader.uniforms()[18], resolved.saturation + 0.35);
1215        assert!((shader.uniforms()[17] - 0.2).abs() < 1.0e-6);
1216        assert_eq!(shader.uniforms()[GLASS_BLUR_RADIUS_UNIFORM], 0.0);
1217        assert_eq!(shader.uniforms()[GLASS_EFFECT_DENSITY_UNIFORM], 2.0);
1218    }
1219
1220    #[test]
1221    fn heavy_backdrop_blur_rides_the_gaussian_pass_alone() {
1222        // Regular glass at phone density: 8dp x 3 = 24px. The Gaussian
1223        // carries the whole radius; the shader's per-pixel tap loop must
1224        // stay out of the steady-state frame cost (uniform 93 at zero takes
1225        // the single-tap path).
1226        let effect = Glass::regular()
1227            .resolve(&light_colors())
1228            .backdrop_effect(3.0, GlassDynamics::default());
1229        let RenderEffect::Chain { first, .. } = &effect else {
1230            panic!("heavy blur must ride the separable Gaussian pre-pass");
1231        };
1232        assert!(matches!(
1233            first.as_ref(),
1234            RenderEffect::Blur {
1235                radius_x: 24.0,
1236                radius_y: 24.0,
1237                ..
1238            }
1239        ));
1240        assert_eq!(
1241            terminal_shader(effect).uniforms()[GLASS_BLUR_RADIUS_UNIFORM],
1242            0.0
1243        );
1244    }
1245
1246    #[test]
1247    fn featherweight_backdrop_blur_stays_in_the_optical_tap() {
1248        // At or below the optical cap there is no Gaussian pass to ride:
1249        // the whole radius stays on the shader uniform, chain-free.
1250        let RenderEffect::Shader { shader } = Glass::regular()
1251            .blur_radius(0.5)
1252            .resolve(&light_colors())
1253            .backdrop_effect(3.0, GlassDynamics::default())
1254        else {
1255            panic!("a sub-cap blur must not build a Gaussian chain");
1256        };
1257        assert_eq!(shader.uniforms()[GLASS_BLUR_RADIUS_UNIFORM], 1.5);
1258    }
1259
1260    #[test]
1261    fn raised_tint_density_stays_premultiplied_alpha_safe() {
1262        let effect = Glass::lens()
1263            .tint(Color::WHITE.with_alpha(0.8))
1264            .resolve(&light_colors())
1265            .backdrop_effect(
1266                1.0,
1267                GlassDynamics {
1268                    tint_alpha_multiplier: Some(2.0),
1269                    ..Default::default()
1270                },
1271            );
1272        assert_eq!(terminal_shader(effect).uniforms()[17], 1.0);
1273    }
1274
1275    #[test]
1276    fn optical_activity_reaches_identity_without_removing_the_glass_geometry() {
1277        let resolved = Glass::lens()
1278            .refraction_depth(0.72)
1279            .refraction_curve(0.8)
1280            .dispersion(0.42)
1281            .blur_radius(3.0)
1282            .saturation(1.4)
1283            .highlight(0.6)
1284            .lift(0.2)
1285            .tint(Color::BLACK.with_alpha(0.8))
1286            .no_clip()
1287            .resolve(&light_colors());
1288        let morph = GlassMorph {
1289            node_size: (120.0, 72.0),
1290            primary: (60.0, 36.0, 96.0, 52.0, -1.0),
1291            wobble_amplitude: 3.0,
1292            bulge_amplitude: 4.0,
1293            deformation: Some(GlassDeformation::incompressible((1.0, 0.0), 1.25)),
1294            ..Default::default()
1295        };
1296
1297        let RenderEffect::Shader { shader } = resolved.backdrop_effect(
1298            2.0,
1299            GlassDynamics {
1300                activity: Some(0.0),
1301                morph: Some(morph),
1302                ..Default::default()
1303            },
1304        ) else {
1305            panic!("material must resolve to the shared wcKSRD shader");
1306        };
1307        let uniforms = shader.uniforms();
1308        assert_eq!(uniforms[GLASS_ACTIVITY_UNIFORM], 0.0);
1309        assert_eq!(uniforms[9], 0.0);
1310        assert_eq!(uniforms[GLASS_REFRACTION_CURVE_UNIFORM], 0.0);
1311        assert_eq!(uniforms[GLASS_DISPERSION_UNIFORM], 0.0);
1312        assert_eq!(uniforms[GLASS_TRANSMISSION_REFRACTION_UNIFORM], 0.0);
1313        assert_eq!(uniforms[11], 0.0);
1314        assert_eq!(uniforms[17], 0.0);
1315        assert_eq!(uniforms[18], 1.0);
1316        assert_eq!(uniforms[20], 0.0);
1317        assert_eq!(uniforms[21], 0.0);
1318        assert_eq!(uniforms[24], 1.0);
1319        assert_eq!(uniforms[28], 0.0);
1320        assert_eq!(uniforms[GLASS_BLUR_RADIUS_UNIFORM], 0.0);
1321        assert_eq!(uniforms[91], 0.0);
1322        assert_eq!(uniforms[102], 0.0);
1323        assert_eq!(
1324            &uniforms[GLASS_RESTING_TINT_UNIFORM..GLASS_RESTING_TINT_UNIFORM + 4],
1325            &[0.0, 0.0, 0.0, 0.0]
1326        );
1327        assert_eq!(uniforms[32], 0.0);
1328        assert_eq!(uniforms[26], 0.0);
1329        assert_eq!(&uniforms[108..110], &[1.0, 1.0]);
1330        assert_eq!(&uniforms[2..6], &[60.0, 36.0, 96.0, 52.0]);
1331    }
1332
1333    #[test]
1334    fn resting_surface_tint_survives_zero_optical_activity() {
1335        let tint = Color::BLACK.with_alpha(0.11);
1336        let RenderEffect::Shader { shader } = Glass::lens()
1337            .no_clip()
1338            .resolve(&light_colors())
1339            .backdrop_effect(
1340                1.0,
1341                GlassDynamics {
1342                    activity: Some(0.0),
1343                    resting_tint: Some(tint),
1344                    ..Default::default()
1345                },
1346            )
1347        else {
1348            panic!("resting surface must use the shared wcKSRD shader");
1349        };
1350        assert_eq!(
1351            &shader.uniforms()[GLASS_RESTING_TINT_UNIFORM..GLASS_RESTING_TINT_UNIFORM + 4],
1352            &[tint.r(), tint.g(), tint.b(), tint.a()]
1353        );
1354        assert_eq!(shader.uniforms()[GLASS_ACTIVITY_UNIFORM], 0.0);
1355    }
1356
1357    #[test]
1358    fn full_optical_activity_preserves_the_resolved_material() {
1359        let resolved = Glass::lens()
1360            .refraction_depth(0.72)
1361            .refraction_curve(0.8)
1362            .dispersion(0.42)
1363            .blur_radius(3.0)
1364            .resolve(&light_colors());
1365        let shader = terminal_shader(resolved.backdrop_effect(
1366            2.0,
1367            GlassDynamics {
1368                activity: Some(1.0),
1369                ..Default::default()
1370            },
1371        ));
1372        let uniforms = shader.uniforms();
1373        assert_eq!(uniforms[GLASS_ACTIVITY_UNIFORM], 1.0);
1374        assert_eq!(uniforms[9], 0.72);
1375        assert_eq!(uniforms[GLASS_REFRACTION_CURVE_UNIFORM], 0.8);
1376        assert_eq!(uniforms[GLASS_DISPERSION_UNIFORM], 0.42);
1377        assert_eq!(uniforms[GLASS_TRANSMISSION_REFRACTION_UNIFORM], 1.0);
1378        // 3dp x 2 = 6px outgrows the optical cap, so the Gaussian pre-pass
1379        // owns the whole radius and the optical tap stays off.
1380        assert_eq!(uniforms[GLASS_BLUR_RADIUS_UNIFORM], 0.0);
1381    }
1382
1383    #[test]
1384    fn morph_geometry_and_incompressible_strain_are_packed() {
1385        let deformation = GlassDeformation::incompressible((0.0, 2.0), 1.25);
1386        let morph = GlassMorph {
1387            node_size: (78.0, 59.0),
1388            primary: (39.0, 29.5, 58.0, 39.0, -1.0),
1389            shapes: vec![(70.0, 29.5, 40.0, 40.0, -1.0)],
1390            glue: 8.0,
1391            wobble_amplitude: 1.0,
1392            wobble_phase: 0.5,
1393            bulge_amplitude: 2.0,
1394            bulge_direction: 0.25,
1395            ellipse_blend: 0.3,
1396            deformation: Some(deformation),
1397            zoom_anchor: (0.0, 0.0),
1398        };
1399        let RenderEffect::Shader { shader } = Glass::lens()
1400            .no_clip()
1401            .resolve(&light_colors())
1402            .backdrop_effect(
1403                1.0,
1404                GlassDynamics {
1405                    morph: Some(morph),
1406                    ..Default::default()
1407                },
1408            )
1409        else {
1410            panic!("morph must use the shared wcKSRD shader");
1411        };
1412        let uniforms = shader.uniforms();
1413        assert_eq!(&uniforms[0..6], &[78.0, 59.0, 39.0, 29.5, 58.0, 39.0]);
1414        assert_eq!(uniforms[30], 1.0);
1415        assert_eq!(&uniforms[106..110], &[0.0, 1.0, 1.25, 0.8]);
1416        assert_eq!(uniforms[110], 0.3);
1417        assert!(shader.output_padding() > 0.0);
1418    }
1419
1420    #[test]
1421    fn optical_builders_hold_their_own_legal_range() {
1422        let glass = Glass::regular()
1423            .fold_depth(-4.0)
1424            .optical_zoom(0.5)
1425            .rim_reflection(9.0);
1426        assert_eq!(glass.fold_depth, 0.0, "a fold cannot be negative deep");
1427        assert_eq!(glass.optical_zoom, 1.0, "a lens never shrinks its face");
1428        assert_eq!(
1429            glass.rim_reflection, 2.0,
1430            "the rim tops out at twice the line"
1431        );
1432
1433        let glass = Glass::regular()
1434            .fold_depth(6.0)
1435            .optical_zoom(1.4)
1436            .rim_reflection(0.25);
1437        assert_eq!(glass.fold_depth, 6.0);
1438        assert_eq!(glass.optical_zoom, 1.4);
1439        assert_eq!(glass.rim_reflection, 0.25);
1440    }
1441
1442    #[test]
1443    fn ink_recolor_records_its_color_at_a_clamped_strength() {
1444        let red = Color::from_rgb_u8(255, 0, 0);
1445        let glass = Glass::regular().ink_recolor(red, 3.0);
1446        assert_eq!(glass.ink_recolor, Some((red, 1.0)));
1447
1448        let glass = Glass::regular().ink_recolor(red, -1.0);
1449        assert_eq!(glass.ink_recolor, Some((red, 0.0)));
1450        assert_eq!(Glass::regular().ink_recolor, None, "no recolor by default");
1451    }
1452
1453    #[test]
1454    fn a_touched_up_surface_amplifies_what_it_already_is() {
1455        let dynamics = GlassDynamics::default().touched_up(1.0, None, (10.0, 4.0));
1456        assert!(
1457            dynamics.highlight_boost > 0.0,
1458            "a pressed surface goes brighter"
1459        );
1460        assert!(dynamics.saturation_boost > 0.0, "and more saturated");
1461        assert_eq!(
1462            dynamics.touch,
1463            Some((10.0, 4.0, 1.0)),
1464            "with no finger the glow sits at the surface's own heart"
1465        );
1466        assert_eq!(
1467            dynamics.resting_tint, None,
1468            "a touch never repaints the surface with another color"
1469        );
1470    }
1471
1472    #[test]
1473    fn a_touch_glow_follows_the_finger_and_accumulates_to_full() {
1474        let dynamics = GlassDynamics::default()
1475            .touched_up(0.5, Some((3.0, 7.0)), (10.0, 4.0))
1476            .touched_up(0.8, Some((3.0, 7.0)), (10.0, 4.0));
1477        let (x, y, intensity) = dynamics.touch.expect("a pressed surface glows");
1478        assert_eq!(
1479            (x, y),
1480            (3.0, 7.0),
1481            "the glow tracks the finger, not the center"
1482        );
1483        assert_eq!(intensity, 1.0, "carried press saturates at full");
1484    }
1485
1486    #[test]
1487    fn an_unpressed_surface_is_left_exactly_as_it_was() {
1488        let resting = GlassDynamics::default();
1489        let untouched = resting
1490            .clone()
1491            .touched_up(0.0, Some((3.0, 7.0)), (10.0, 4.0));
1492        assert_eq!(untouched.highlight_boost, resting.highlight_boost);
1493        assert_eq!(untouched.saturation_boost, resting.saturation_boost);
1494        assert_eq!(untouched.touch, None, "no press, no glow");
1495    }
1496}