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