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::{
7    cell::{Cell, RefCell},
8    sync::OnceLock,
9};
10
11use cranpose_ui::{Modifier, current_density};
12use cranpose_ui_graphics::{
13    Color, GLASS_ACTIVITY_UNIFORM, GLASS_ADAPTIVE_FROST_UNIFORM, GLASS_BLUR_RADIUS_UNIFORM,
14    GLASS_DISPERSION_UNIFORM, GLASS_EFFECT_DENSITY_UNIFORM, GLASS_FOLD_DEPTH_UNIFORM,
15    GLASS_LIGHT_DIRECTION_UNIFORM, GLASS_MENISCUS_ABSORPTION_UNIFORM,
16    GLASS_OPTICAL_ZOOM_ANCHOR_UNIFORM, GLASS_OPTICAL_ZOOM_UNIFORM,
17    GLASS_PHYSICAL_REFRACTION_DEPTH_ENABLED_UNIFORM, GLASS_PHYSICAL_REFRACTION_DEPTH_UNIFORM,
18    GLASS_REFRACTION_CURVE_UNIFORM, GLASS_RESTING_TINT_UNIFORM,
19    GLASS_TRANSMISSION_REFRACTION_UNIFORM, GraphicsLayer, LIQUID_GLASS_WGSL, LayerShape, Rect,
20    RenderEffect, RoundedCornerShape, RuntimeShader, TileMode, liquid_glass_runtime_effect,
21    specialize_liquid_glass,
22};
23
24use crate::theme::LiquidColors;
25
26thread_local! {
27    static GLASS_LIGHT_RETURN: Cell<(f32, f32)> = const { Cell::new((0.0, 1.0)) };
28}
29
30/// Sets the ambient glass light return direction (screen space, need not be
31/// normalized — the shader normalizes). Feed device attitude here.
32pub fn set_glass_light_direction(direction: (f32, f32)) {
33    GLASS_LIGHT_RETURN.with(|cell| cell.set(direction));
34}
35
36/// The current ambient glass light return direction.
37pub fn glass_light_direction() -> (f32, f32) {
38    GLASS_LIGHT_RETURN.with(|cell| cell.get())
39}
40
41const CAPSULE_CLIP_RADIUS: f32 = 1.0e6;
42
43const CAPSULE_SHADER_RADIUS: f32 = -1.0;
44
45const WCKSRD_OPTICAL_BLUR_RADIUS_PX: f32 = 2.0;
46
47/// The shape of a glass element (also its clip and shadow shape).
48#[derive(Clone, Copy, Debug, PartialEq, Default)]
49pub enum LiquidShape {
50    /// A pill: corner radius follows the smaller half-extent.
51    #[default]
52    Capsule,
53    /// Rounded rectangle with the radius in dp.
54    RoundedRect(f32),
55    /// A circle (capsule of a square node).
56    Circle,
57}
58
59impl LiquidShape {
60    /// The clip shape handed to the graphics layer.
61    pub fn clip_shape(&self) -> RoundedCornerShape {
62        match self {
63            LiquidShape::Capsule | LiquidShape::Circle => {
64                RoundedCornerShape::uniform(CAPSULE_CLIP_RADIUS)
65            }
66            LiquidShape::RoundedRect(radius) => RoundedCornerShape::uniform(*radius),
67        }
68    }
69
70    /// The layer shape (clip + shadow geometry).
71    pub fn layer_shape(&self) -> LayerShape {
72        LayerShape::Rounded(self.clip_shape())
73    }
74
75    fn shader_radius_px(&self, density: f32) -> f32 {
76        match self {
77            LiquidShape::Capsule | LiquidShape::Circle => CAPSULE_SHADER_RADIUS,
78            LiquidShape::RoundedRect(radius) => radius * density,
79        }
80    }
81}
82
83/// Material variant, mirroring SwiftUI's `.regular` / `.clear` glass, plus
84/// the interactive lens.
85#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
86pub enum GlassVariant {
87    /// Frosted: strong backdrop blur, vibrancy boost, scheme-adaptive lift.
88    #[default]
89    Regular,
90    /// Transparent: minimal blur, mild vibrancy — for media-rich backdrops.
91    Clear,
92    /// The interactive magnifying bubble (drag lenses, flying selection):
93    /// no frost, no tone shift, a full-element dome and pronounced rainbow
94    /// dispersion at the rim.
95    Lens,
96}
97
98/// Shadow owned by a glass surface. Morphing glass evaluates the same live
99/// SDF for this shadow; clipped static glass forwards the values to the layer
100/// shadow primitive.
101#[derive(Clone, Copy, Debug, PartialEq)]
102pub struct GlassShadow {
103    pub color: Color,
104    pub radius: f32,
105    pub offset_y: f32,
106    pub spread: f32,
107}
108
109impl GlassShadow {
110    pub fn new(color: Color, radius: f32, offset_y: f32, spread: f32) -> Self {
111        Self {
112            color,
113            radius: radius.max(0.0),
114            offset_y,
115            spread,
116        }
117    }
118}
119
120/// Per-frame motion inputs for an interactive glass element, read lazily at
121/// scene-build time (no recomposition per frame).
122#[derive(Clone, Debug, PartialEq, Default)]
123pub struct GlassDynamics {
124    /// Continuous optical presence. `None` preserves the resolved material;
125    /// `Some(0)` is an exact backdrop identity while retaining the same node,
126    /// SDF, and pointer ride path.
127    pub activity: Option<f32>,
128    /// Base tint of the same persistent SDF surface at zero optical activity.
129    /// Its alpha cross-fades out as the refractive material rises.
130    pub resting_tint: Option<Color>,
131    /// Extra specular intensity (0 = spec default; e.g. press boost).
132    pub highlight_boost: f32,
133    /// Per-frame saturation added to the resolved material. Interactive
134    /// surfaces use this to raise chroma without changing their base tint.
135    pub saturation_boost: f32,
136    /// Optional per-frame multiplier for the material tint alpha. Values
137    /// below one clear a resting wash; values above one densify a raised tint.
138    pub tint_alpha_multiplier: Option<f32>,
139    /// Shape morph: when set, the glass geometry is these node-local rects
140    /// instead of the node cover — the shapeshift channel.
141    pub morph: Option<GlassMorph>,
142    /// Touch glow: `(x_dp, y_dp, intensity)` in node-local dp. A pressed
143    /// surface concentrates saturation and a soft light in a radial
144    /// gradient under the finger (never a flat recolor).
145    pub touch: Option<(f32, f32, f32)>,
146    /// Dome press depth: how squashed the interactive dome is. At 1 the
147    /// pressed dome refracts deep and vivid (wide rim band, strong
148    /// chromatic split — the reference toggle's gray-hold rainbow); toward
149    /// 0 the released bead relaxes shallow and its split fades with it
150    /// (the reference's thin settled ring). `None` = 1.
151    pub press_depth: Option<f32>,
152}
153
154impl GlassDynamics {
155    /// The universal touched-up state (user-directed law): a pressed
156    /// liquid surface comes closer to the user while its OWN colors go
157    /// HDR-bright and saturated, with a radial glow following the finger.
158    /// Never a repaint with alternate colors — the boosts amplify whatever
159    /// the surface already is. `press` is 0..1; `finger` is node-local dp
160    /// (falls back to `center`, the surface's own heart).
161    pub fn touched_up(
162        mut self,
163        press: f32,
164        finger: Option<(f32, f32)>,
165        center: (f32, f32),
166    ) -> Self {
167        let press = press.clamp(0.0, 1.0);
168        if press <= 0.0 {
169            return self;
170        }
171        self.highlight_boost += 0.85 * press;
172        self.saturation_boost += 0.45 * press;
173        let (fx, fy) = finger.unwrap_or(center);
174        let carried = self.touch.map(|(_, _, i)| i).unwrap_or(0.0);
175        self.touch = Some((fx, fy, (carried + press).clamp(0.0, 1.0)));
176        self
177    }
178}
179
180fn foreground_is_dark(foreground: Color) -> bool {
181    let foreground_luma =
182        0.2126 * foreground.r() + 0.7152 * foreground.g() + 0.0722 * foreground.b();
183    foreground_luma < 0.5
184}
185
186fn boost_tint_saturation(tint: Color, boost: f32) -> Color {
187    if boost.abs() <= f32::EPSILON {
188        return tint;
189    }
190    let luma = 0.2126 * tint.r() + 0.7152 * tint.g() + 0.0722 * tint.b();
191    let saturation = (1.0 + boost).max(0.0);
192    Color::rgba(
193        (luma + (tint.r() - luma) * saturation).clamp(0.0, 1.0),
194        (luma + (tint.g() - luma) * saturation).clamp(0.0, 1.0),
195        (luma + (tint.b() - luma) * saturation).clamp(0.0, 1.0),
196        tint.a(),
197    )
198}
199
200pub(crate) fn neutral_surface_tint(foreground: Color, light_alpha: f32, dark_alpha: f32) -> Color {
201    if foreground_is_dark(foreground) {
202        Color::BLACK.with_alpha(light_alpha.clamp(0.0, 1.0))
203    } else {
204        Color::WHITE.with_alpha(dark_alpha.clamp(0.0, 1.0))
205    }
206}
207
208pub(crate) fn neutral_surface_lift(foreground: Color, light_lift: f32, dark_lift: f32) -> f32 {
209    if foreground_is_dark(foreground) {
210        light_lift
211    } else {
212        dark_lift
213    }
214}
215
216/// A liquid shapeshift frame: the primary shape plus any number of nearby
217/// glass shapes, ALL smooth-unioned into one field — liquid glass glues to
218/// whatever glass it passes near (a growing menu necks with a neighboring
219/// button, the drag lens merges with the search circle). Up to
220/// [`GlassMorph::MAX_SHAPES`] extra shapes; an angular wobble makes the
221/// mid-flight field bubble like a droplet. All geometry is node-local dp:
222/// `(center_x, center_y, width, height, corner_radius)`; radius sentinel
223/// `-1` means capsule; `-2` means SUBTRACT capsule — the shape carves a
224/// smooth hole in the field (the growing menu leaves its anchor button
225/// crisp on top until it swallows it).
226#[derive(Clone, Debug, PartialEq, Default)]
227pub struct GlassMorph {
228    /// The glass node's own size in dp. The shader receives all morph
229    /// geometry in dp and derives px-per-dp from the renderer-injected node
230    /// pixel rect divided by this — geometry then lands correctly at ANY
231    /// render scale (live window density, robot captures at 1.0, fractional
232    /// desktop scales). The authoring widget always knows its node size.
233    pub node_size: (f32, f32),
234    pub primary: (f32, f32, f32, f32, f32),
235    /// Nearby glass shapes participating in the field.
236    pub shapes: Vec<(f32, f32, f32, f32, f32)>,
237    /// Smooth-union glue radius (dp): shapes within it neck together.
238    pub glue: f32,
239    /// Wobble amplitude (dp) and phase (radians).
240    pub wobble_amplitude: f32,
241    pub wobble_phase: f32,
242    /// Viscous leading-edge bulge: while the shape travels or inflates, its
243    /// side facing `bulge_direction` (radians, math convention) swells like a
244    /// pulled droplet. Amplitude in dp, usually driven by morph velocity.
245    pub bulge_amplitude: f32,
246    pub bulge_direction: f32,
247    /// Blends the primary rounded-rectangle field toward an ellipse. This is
248    /// used by expanding droplets whose broad phase has continuous curvature
249    /// rather than the straight sides of a capsule.
250    pub ellipse_blend: f32,
251    /// Area-preserving affine strain applied to the primary shape. Extra
252    /// scene shapes remain fixed so nearby glass can join the travelling
253    /// droplet without being dragged through its local deformation.
254    pub deformation: Option<GlassDeformation>,
255    /// Offset (dp) of the optical-zoom axis from the primary SDF center. A
256    /// leaning droplet's silhouette shifts toward its travel side while its
257    /// curvature apex stays over the content it rides; anchoring the
258    /// magnification there keeps the face filled by that content instead of
259    /// pulling in whatever lies beyond it.
260    pub zoom_anchor: (f32, f32),
261}
262
263/// A normalized motion axis and reciprocal scales for incompressible glass.
264/// Construction derives the cross-axis scale, so callers cannot describe a
265/// deformation that changes the bubble's area.
266#[derive(Clone, Copy, Debug, PartialEq)]
267pub struct GlassDeformation {
268    axis: (f32, f32),
269    along: f32,
270}
271
272impl GlassDeformation {
273    pub fn incompressible(axis: (f32, f32), along: f32) -> Self {
274        let length = (axis.0 * axis.0 + axis.1 * axis.1).sqrt();
275        let axis = if length > f32::EPSILON {
276            (axis.0 / length, axis.1 / length)
277        } else {
278            (1.0, 0.0)
279        };
280        Self {
281            axis,
282            along: along.max(f32::EPSILON),
283        }
284    }
285
286    pub fn axis(self) -> (f32, f32) {
287        self.axis
288    }
289
290    pub fn along(self) -> f32 {
291        self.along
292    }
293
294    pub fn across(self) -> f32 {
295        1.0 / self.along
296    }
297}
298
299impl GlassMorph {
300    /// Shader budget for extra scene shapes.
301    pub const MAX_SHAPES: usize = 8;
302}
303
304/// Builder describing a glass material. Resolved against the theme at the
305/// composition site, then evaluated per frame for density and dynamics.
306#[derive(Clone, Debug, PartialEq)]
307pub struct Glass {
308    pub variant: GlassVariant,
309    pub shape: LiquidShape,
310    /// Tint over the refracted backdrop; defaults to the theme's glass tint.
311    pub tint: Option<Color>,
312    /// Backdrop blur radius in dp (defaults per variant).
313    pub blur_radius: Option<f32>,
314    /// Saturation boost (defaults per variant).
315    pub saturation: Option<f32>,
316    /// wcKSRD refraction depth as a fraction of the shape inradius. Regular
317    /// frosted surfaces default to zero so their backdrop stays motion-stable;
318    /// interactive lenses opt into optical displacement explicitly.
319    pub refraction_depth: f32,
320    /// Optional physical wcKSRD refraction depth in dp. When present, this
321    /// keeps the optical band equally deep across surfaces with different
322    /// inradii instead of scaling it from each surface's height.
323    pub refraction_depth_dp: Option<f32>,
324    /// Normalized wcKSRD ray-return exponent.
325    pub refraction_curve: f32,
326    /// Normalized wcKSRD spectral ray separation.
327    pub dispersion: f32,
328    /// Fraction of the wcKSRD displacement applied to the transmitted
329    /// backdrop. Regular frosted surfaces default to zero. The mirrored
330    /// meniscus follows its own optical path.
331    pub transmission_refraction: f32,
332    /// Energy removed from the transmitted ray at the meniscus. Reflection
333    /// and spectral return follow independent paths.
334    pub meniscus_absorption: f32,
335    /// Depth of the rim fold band in dp: the raised lens rim replays its
336    /// interior mirrored toward the edge (a pure displacement). Zero
337    /// disables the fold.
338    pub fold_depth: f32,
339    /// Uniform face magnification of a riding lens (1.0 = no zoom): the
340    /// backdrop projects enlarged across the whole face while the rim band
341    /// keeps the wcKSRD edge mapping.
342    pub optical_zoom: f32,
343    /// Meniscus rim reflectivity multiplier (1.0 = the reference toggle's
344    /// visible rim line; near 0 = the segmented lens's invisible body).
345    pub rim_reflection: f32,
346    /// Optical ink recolor: the lens recolors dark transmitted ink toward
347    /// this color at the given strength (the reference tab bubble's
348    /// color-mask act). None = off.
349    pub ink_recolor: Option<(Color, f32)>,
350    /// Specular rim intensity.
351    pub highlight: f32,
352    /// Screen-lift override (brightening toward white; negative darkens).
353    /// Defaults per variant.
354    pub lift: Option<f32>,
355    /// Tone-compression override around the shader's mid pivot (<1 pulls
356    /// every backdrop toward mid-luminance — the reference dark menu reads
357    /// bright magenta over deep purple AND dim gray over white through one
358    /// law). Defaults per variant.
359    pub contrast: Option<f32>,
360    /// Drop shadow below the glass.
361    pub shadow: bool,
362    /// Per-surface shadow override.
363    pub shadow_style: Option<GlassShadow>,
364    /// Clip the layer to `shape`. Morphing glass disables this — coverage
365    /// comes entirely from the shader's SDF.
366    pub clip: bool,
367    /// Foreground color whose contrast the frost must protect. `None` uses
368    /// the theme label color.
369    pub foreground: Option<Color>,
370    /// Strength of backdrop+foreground frost adaptation.
371    pub adaptive_frost: f32,
372}
373
374impl Glass {
375    /// A motion-stable frosted surface without optical displacement.
376    pub fn regular() -> Self {
377        Self {
378            variant: GlassVariant::Regular,
379            shape: LiquidShape::Capsule,
380            tint: None,
381            blur_radius: None,
382            saturation: None,
383            refraction_depth: 0.0,
384            refraction_depth_dp: None,
385            refraction_curve: 0.25,
386            dispersion: 0.0,
387            transmission_refraction: 0.0,
388            meniscus_absorption: 1.0,
389            fold_depth: 0.0,
390            optical_zoom: 1.0,
391            rim_reflection: 1.0,
392            ink_recolor: None,
393            highlight: 0.9,
394            lift: None,
395            contrast: None,
396            shadow: true,
397            shadow_style: None,
398            clip: true,
399            foreground: None,
400            adaptive_frost: 0.65,
401        }
402    }
403
404    pub fn clear() -> Self {
405        Self {
406            variant: GlassVariant::Clear,
407            ..Self::regular()
408        }
409    }
410
411    /// The interactive lens bubble (reference: the iOS toggle/tab-bar drag
412    /// lens): fully transparent, magnifying dome across the whole element,
413    /// strong rainbow rim.
414    pub fn lens() -> Self {
415        Self {
416            variant: GlassVariant::Lens,
417            shape: LiquidShape::Capsule,
418            tint: Some(Color::rgba(1.0, 1.0, 1.0, 0.07)),
419            blur_radius: None,
420            saturation: None,
421            refraction_depth: 0.34,
422            refraction_depth_dp: None,
423            refraction_curve: 1.0,
424            dispersion: 0.30,
425            transmission_refraction: 1.0,
426            meniscus_absorption: 1.0,
427            fold_depth: 0.0,
428            optical_zoom: 1.0,
429            rim_reflection: 1.0,
430            ink_recolor: None,
431            highlight: 1.15,
432            lift: None,
433            contrast: None,
434            shadow: true,
435            shadow_style: None,
436            clip: true,
437            foreground: None,
438            adaptive_frost: 0.0,
439        }
440    }
441
442    pub fn shape(mut self, shape: LiquidShape) -> Self {
443        self.shape = shape;
444        self
445    }
446
447    pub fn tint(mut self, tint: Color) -> Self {
448        self.tint = Some(tint);
449        self
450    }
451
452    pub fn blur_radius(mut self, radius_dp: f32) -> Self {
453        self.blur_radius = Some(radius_dp);
454        self
455    }
456
457    pub fn saturation(mut self, saturation: f32) -> Self {
458        self.saturation = Some(saturation);
459        self
460    }
461
462    pub fn refraction_depth(mut self, fraction: f32) -> Self {
463        self.refraction_depth = fraction.clamp(0.0, 2.0);
464        self.refraction_depth_dp = None;
465        self
466    }
467
468    /// Uses a density-stable physical refraction depth instead of a fraction
469    /// of the live shape inradius.
470    pub fn refraction_depth_dp(mut self, depth_dp: f32) -> Self {
471        self.refraction_depth_dp = Some(depth_dp.max(0.0));
472        self
473    }
474
475    pub fn refraction_curve(mut self, curve: f32) -> Self {
476        self.refraction_curve = curve.clamp(0.05, 1.0);
477        self
478    }
479
480    pub fn dispersion(mut self, strength: f32) -> Self {
481        self.dispersion = strength.clamp(0.0, 1.0);
482        self
483    }
484
485    pub fn transmission_refraction(mut self, strength: f32) -> Self {
486        self.transmission_refraction = strength.clamp(0.0, 1.0);
487        self
488    }
489
490    pub fn meniscus_absorption(mut self, strength: f32) -> Self {
491        self.meniscus_absorption = strength.clamp(0.0, 1.0);
492        self
493    }
494
495    /// Sets the rim fold band depth in dp (zero disables the fold).
496    pub fn fold_depth(mut self, depth_dp: f32) -> Self {
497        self.fold_depth = depth_dp.max(0.0);
498        self
499    }
500
501    /// Sets the uniform face magnification of a riding lens (1.0 = none).
502    pub fn optical_zoom(mut self, zoom: f32) -> Self {
503        self.optical_zoom = zoom.max(1.0);
504        self
505    }
506
507    /// Sets the meniscus rim reflectivity (1.0 = full reference line).
508    pub fn rim_reflection(mut self, reflectivity: f32) -> Self {
509        self.rim_reflection = reflectivity.clamp(0.0, 2.0);
510        self
511    }
512
513    /// The lens recolors dark transmitted ink toward `color` at `strength`
514    /// (0..1) — the reference bubble's color-mask act as a material optic.
515    pub fn ink_recolor(mut self, color: Color, strength: f32) -> Self {
516        self.ink_recolor = Some((color, strength.clamp(0.0, 1.0)));
517        self
518    }
519
520    pub fn highlight(mut self, highlight: f32) -> Self {
521        self.highlight = highlight;
522        self
523    }
524
525    /// Overrides the screen-lift (how hard the glass brightens what it
526    /// shows; the bar lens uses a near-zero lift to stay transmissive).
527    pub fn lift(mut self, lift: f32) -> Self {
528        self.lift = Some(lift);
529        self
530    }
531
532    /// Overrides the tone compression around the shader's mid pivot
533    /// (values below one pull every backdrop toward mid-luminance).
534    pub fn contrast(mut self, contrast: f32) -> Self {
535        self.contrast = Some(contrast.max(0.05));
536        self
537    }
538
539    pub fn adaptive_frost(mut self, foreground: Color, strength: f32) -> Self {
540        self.foreground = Some(foreground);
541        self.adaptive_frost = strength.clamp(0.0, 1.0);
542        self
543    }
544
545    pub fn shadow(mut self, shadow: bool) -> Self {
546        self.shadow = shadow;
547        self
548    }
549
550    pub fn shadow_style(mut self, shadow: GlassShadow) -> Self {
551        self.shadow_style = Some(shadow);
552        self
553    }
554
555    /// Disables the layer clip: the shader's SDF coverage is the only shape
556    /// (required while morphing across geometry the clip can't follow).
557    pub fn no_clip(mut self) -> Self {
558        self.clip = false;
559        self
560    }
561
562    fn default_blur_radius(&self) -> f32 {
563        match self.variant {
564            GlassVariant::Regular => 8.0,
565            GlassVariant::Clear => 3.0,
566            GlassVariant::Lens => 0.0,
567        }
568    }
569
570    fn default_saturation(&self) -> f32 {
571        match self.variant {
572            GlassVariant::Regular => 1.5,
573            GlassVariant::Clear => 1.25,
574            GlassVariant::Lens => 1.0,
575        }
576    }
577
578    /// The backdrop effect this material produces for a node at `density`
579    /// under `dynamics`, resolved against `colors`: the render effect the
580    /// glass modifiers install, for a layer built without them.
581    pub fn backdrop_effect(
582        &self,
583        colors: &LiquidColors,
584        density: f32,
585        dynamics: GlassDynamics,
586    ) -> RenderEffect {
587        self.resolve(colors).backdrop_effect(density, dynamics)
588    }
589
590    pub(crate) fn resolve(&self, colors: &LiquidColors) -> ResolvedGlass {
591        let lift = self.lift.unwrap_or(match (self.variant, colors.is_dark) {
592            (GlassVariant::Regular, false) => 0.42,
593            (GlassVariant::Regular, true) => -0.38,
594            (GlassVariant::Clear, false) => 0.12,
595            (GlassVariant::Clear, true) => -0.12,
596            (GlassVariant::Lens, false) => 0.10,
597            (GlassVariant::Lens, true) => -0.08,
598        });
599        let foreground = self.foreground.unwrap_or(colors.label);
600        let shadow = self.shadow_style.unwrap_or_else(|| {
601            GlassShadow::new(
602                Color::BLACK.with_alpha(match (self.variant, colors.is_dark) {
603                    (GlassVariant::Lens, false) => 0.14,
604                    (GlassVariant::Lens, true) => 0.28,
605                    (_, false) => 0.16,
606                    (_, true) => 0.5,
607                }),
608                if self.variant == GlassVariant::Lens {
609                    10.0
610                } else {
611                    22.0
612                },
613                if self.variant == GlassVariant::Lens {
614                    3.0
615                } else {
616                    8.0
617                },
618                if self.variant == GlassVariant::Lens {
619                    -6.0
620                } else {
621                    -2.0
622                },
623            )
624        });
625        ResolvedGlass {
626            shape: self.shape,
627            tint: self.tint.unwrap_or(colors.glass_tint),
628            blur_radius_dp: self
629                .blur_radius
630                .unwrap_or_else(|| self.default_blur_radius()),
631            saturation: self.saturation.unwrap_or_else(|| self.default_saturation()),
632            refraction_depth: self.refraction_depth,
633            refraction_depth_dp: self.refraction_depth_dp,
634            refraction_curve: self.refraction_curve,
635            dispersion: self.dispersion,
636            transmission_refraction: self.transmission_refraction,
637            meniscus_absorption: self.meniscus_absorption,
638            fold_depth: self.fold_depth,
639            optical_zoom: self.optical_zoom,
640            rim_reflection: self.rim_reflection,
641            ink_recolor: self.ink_recolor,
642            highlight: self.highlight,
643            lift,
644            contrast: self.contrast.unwrap_or(match self.variant {
645                GlassVariant::Lens => 1.0,
646                _ => 1.03,
647            }),
648            shadow: self.shadow,
649            clip: self.clip,
650            foreground_luma: 0.2126 * foreground.r()
651                + 0.7152 * foreground.g()
652                + 0.0722 * foreground.b(),
653            adaptive_frost: self.adaptive_frost,
654            rim_style: if self.variant == GlassVariant::Lens {
655                1.0
656            } else {
657                0.0
658            },
659            shadow_color: shadow.color,
660            shadow_radius: shadow.radius,
661            shadow_offset_y: shadow.offset_y,
662            shadow_spread: shadow.spread,
663        }
664    }
665}
666
667impl Default for Glass {
668    fn default() -> Self {
669        Self::regular()
670    }
671}
672
673#[derive(Clone, Debug, PartialEq)]
674pub(crate) struct ResolvedGlass {
675    pub shape: LiquidShape,
676    pub tint: Color,
677    pub blur_radius_dp: f32,
678    pub saturation: f32,
679    pub refraction_depth: f32,
680    pub refraction_depth_dp: Option<f32>,
681    pub refraction_curve: f32,
682    pub dispersion: f32,
683    pub transmission_refraction: f32,
684    pub meniscus_absorption: f32,
685    pub fold_depth: f32,
686    pub optical_zoom: f32,
687    pub rim_reflection: f32,
688    pub ink_recolor: Option<(Color, f32)>,
689    pub highlight: f32,
690    pub lift: f32,
691    pub contrast: f32,
692    pub shadow: bool,
693    pub clip: bool,
694    pub rim_style: f32,
695    pub foreground_luma: f32,
696    pub adaptive_frost: f32,
697    pub shadow_color: Color,
698    pub shadow_radius: f32,
699    pub shadow_offset_y: f32,
700    pub shadow_spread: f32,
701}
702
703impl ResolvedGlass {
704    pub(crate) fn backdrop_effect(&self, density: f32, dynamics: GlassDynamics) -> RenderEffect {
705        self.runtime_effect(density, &dynamics, false)
706    }
707
708    fn runtime_effect(
709        &self,
710        density: f32,
711        dynamics: &GlassDynamics,
712        content_mask: bool,
713    ) -> RenderEffect {
714        let density = density.max(f32::EPSILON);
715        let activity = dynamics
716            .activity
717            .filter(|value| value.is_finite())
718            .unwrap_or(1.0)
719            .clamp(0.0, 1.0);
720        static SHADER: OnceLock<RuntimeShader> = OnceLock::new();
721        let mut shader = SHADER
722            .get_or_init(|| {
723                let mut shader = RuntimeShader::new(LIQUID_GLASS_WGSL);
724                shader.set_float(GLASS_ACTIVITY_UNIFORM, 1.0);
725                shader.set_float2(GLASS_OPTICAL_ZOOM_ANCHOR_UNIFORM, 0.0, 0.0);
726                specialize_liquid_glass(&mut shader);
727                shader
728            })
729            .clone();
730        if let Some(morph) = dynamics.morph.as_ref() {
731            set_morph_uniforms(&mut shader, morph, activity);
732        } else {
733            shader.set_float2(0, 0.0, 0.0);
734            shader.set_float(6, self.shape.shader_radius_px(density));
735        }
736        let press_depth = dynamics.press_depth.unwrap_or(1.0).clamp(0.0, 1.0);
737        shader.set_float(9, self.refraction_depth * activity * press_depth);
738        shader.set_float(
739            GLASS_PHYSICAL_REFRACTION_DEPTH_UNIFORM,
740            self.refraction_depth_dp.unwrap_or(0.0) * activity * press_depth,
741        );
742        shader.set_float(
743            GLASS_PHYSICAL_REFRACTION_DEPTH_ENABLED_UNIFORM,
744            if self.refraction_depth_dp.is_some() {
745                1.0
746            } else {
747                0.0
748            },
749        );
750        shader.set_float(
751            GLASS_REFRACTION_CURVE_UNIFORM,
752            self.refraction_curve * activity,
753        );
754        shader.set_float(
755            GLASS_DISPERSION_UNIFORM,
756            self.dispersion * activity * press_depth,
757        );
758        shader.set_float(
759            GLASS_TRANSMISSION_REFRACTION_UNIFORM,
760            self.transmission_refraction * activity,
761        );
762        shader.set_float(
763            GLASS_MENISCUS_ABSORPTION_UNIFORM,
764            self.meniscus_absorption * press_depth,
765        );
766        shader.set_float(
767            GLASS_FOLD_DEPTH_UNIFORM,
768            self.fold_depth.max(0.0) * press_depth,
769        );
770        shader.set_float(
771            GLASS_OPTICAL_ZOOM_UNIFORM,
772            1.0 + (self.optical_zoom - 1.0).max(0.0) * activity,
773        );
774        let zoom_anchor = dynamics
775            .morph
776            .as_ref()
777            .map(|morph| morph.zoom_anchor)
778            .unwrap_or((0.0, 0.0));
779        shader.set_float2(
780            GLASS_OPTICAL_ZOOM_ANCHOR_UNIFORM,
781            zoom_anchor.0,
782            zoom_anchor.1,
783        );
784        shader.set_float(121, self.rim_reflection.max(0.001));
785        let (ink_color, ink_strength) = self
786            .ink_recolor
787            .map(|(color, strength)| (color, strength * activity))
788            .unwrap_or((Color::TRANSPARENT, 0.0));
789        shader.set_float(124, ink_color.r());
790        shader.set_float(125, ink_color.g());
791        shader.set_float(126, ink_color.b());
792        shader.set_float(127, ink_strength);
793        let (light_x, light_y) = glass_light_direction();
794        shader.set_float(GLASS_LIGHT_DIRECTION_UNIFORM, light_x);
795        shader.set_float(GLASS_LIGHT_DIRECTION_UNIFORM + 1, light_y);
796        let (touch_x, touch_y, touch_intensity) = dynamics.touch.unwrap_or((0.0, 0.0, 0.0));
797        shader.set_float(118, touch_x);
798        shader.set_float(119, touch_y);
799        shader.set_float(120, touch_intensity.clamp(0.0, 1.0));
800        shader.set_float(GLASS_EFFECT_DENSITY_UNIFORM, density);
801        shader.set_float(
802            11,
803            (self.highlight + dynamics.highlight_boost).clamp(0.0, 2.0) * activity,
804        );
805        let dynamic_tint = boost_tint_saturation(self.tint, dynamics.saturation_boost);
806        let dynamic_tint_alpha = (dynamic_tint.a()
807            * dynamics
808                .tint_alpha_multiplier
809                .unwrap_or(1.0)
810                .clamp(0.0, 2.0)
811            * activity)
812            .clamp(0.0, 1.0);
813        shader.set_float4(
814            14,
815            dynamic_tint.r(),
816            dynamic_tint.g(),
817            dynamic_tint.b(),
818            dynamic_tint_alpha,
819        );
820        let saturation = (self.saturation + dynamics.saturation_boost).max(0.0);
821        shader.set_float(18, 1.0 + (saturation - 1.0) * activity);
822        shader.set_float(20, self.lift * activity);
823        shader.set_float(21, 0.5 * activity);
824        shader.set_float2(22, 0.0, 1.0);
825        shader.set_float(24, 1.0 + (self.contrast - 1.0) * activity);
826        shader.set_float(28, self.rim_style * activity);
827        let requested_blur_radius_px = if content_mask {
828            0.0
829        } else {
830            self.blur_radius_dp * density * activity
831        };
832        let (wcksrd_blur_radius, gaussian_blur_radius) =
833            if requested_blur_radius_px > WCKSRD_OPTICAL_BLUR_RADIUS_PX {
834                (0.0, requested_blur_radius_px)
835            } else {
836                (requested_blur_radius_px, 0.0)
837            };
838        shader.set_float(GLASS_BLUR_RADIUS_UNIFORM, wcksrd_blur_radius);
839        shader.set_float(GLASS_ACTIVITY_UNIFORM, activity);
840        let resting_tint = dynamics.resting_tint.unwrap_or(Color::TRANSPARENT);
841        shader.set_float4(
842            GLASS_RESTING_TINT_UNIFORM,
843            resting_tint.r(),
844            resting_tint.g(),
845            resting_tint.b(),
846            resting_tint.a(),
847        );
848        shader.set_float(112, if content_mask { 1.0 } else { 0.0 });
849        shader.set_float(GLASS_ADAPTIVE_FROST_UNIFORM, self.adaptive_frost * activity);
850        shader.set_float(97, self.foreground_luma);
851        let dynamic_shadow = !self.clip && self.shadow;
852        shader.set_float(
853            102,
854            if dynamic_shadow {
855                self.shadow_color.a() * 0.55 * activity
856            } else {
857                0.0
858            },
859        );
860        shader.set_float(103, self.shadow_radius);
861        shader.set_float(104, self.shadow_offset_y);
862        shader.set_float(105, self.shadow_spread);
863        let morph_pad = dynamics
864            .morph
865            .as_ref()
866            .map(|morph| {
867                let (px, py, pw, ph, _) = morph.primary;
868                let (left, top) = (px - pw * 0.5, py - ph * 0.5);
869                let (right, bottom) = (px + pw * 0.5, py + ph * 0.5);
870                let mut shape_reach = 0.0f32;
871                for (sx, sy, sw, sh, _) in &morph.shapes {
872                    let reach_x = ((sx + sw * 0.5) - right)
873                        .max(left - (sx - sw * 0.5))
874                        .max(0.0);
875                    let reach_y = ((sy + sh * 0.5) - bottom)
876                        .max(top - (sy - sh * 0.5))
877                        .max(0.0);
878                    shape_reach = shape_reach.max(reach_x.max(reach_y));
879                }
880                let glue_pad = if morph.shapes.is_empty() {
881                    0.0
882                } else {
883                    morph.glue * 2.0
884                };
885                morph.wobble_amplitude * 2.0 + morph.bulge_amplitude + shape_reach + glue_pad
886            })
887            .unwrap_or(0.0);
888        shader.set_input_padding(self.input_padding() + morph_pad + wcksrd_blur_radius / density);
889        if let Some(morph) = dynamics.morph.as_ref() {
890            let shadow_reach = if dynamic_shadow {
891                self.shadow_radius + self.shadow_offset_y.abs() + self.shadow_spread.max(0.0)
892            } else {
893                0.0
894            };
895            let support = morph_output_support(
896                morph,
897                activity,
898                morph_pad,
899                shadow_reach,
900                if dynamic_shadow {
901                    self.shadow_offset_y.abs()
902                } else {
903                    0.0
904                },
905            );
906            let (node_w, node_h) = morph.node_size;
907            let overhang = (-support.x)
908                .max(-support.y)
909                .max(support.x + support.width - node_w)
910                .max(support.y + support.height - node_h)
911                .max(0.0);
912            shader.set_output_padding((morph_pad + shadow_reach + 4.0).max(overhang));
913            shader.set_output_support(Some(support));
914        }
915
916        let optical_effect = liquid_glass_runtime_effect(shader);
917        if gaussian_blur_radius > f32::EPSILON {
918            RenderEffect::blur_with_edge_treatment(gaussian_blur_radius, TileMode::Mirror)
919                .then(optical_effect)
920        } else {
921            optical_effect
922        }
923    }
924
925    fn input_padding(&self) -> f32 {
926        2.0
927    }
928}
929
930/// Modifier extension installing the Liquid Glass material.
931pub trait LiquidModifierExt {
932    /// Applies the glass material to this composable's bounds: backdrop blur +
933    /// lens shader, clipped to `glass.shape`, with a soft drop shadow.
934    ///
935    /// Must be called in composable context (the material resolves theme
936    /// colors at the call site).
937    fn glass_effect(self, glass: Glass) -> Modifier;
938
939    /// [`glass_effect`](Self::glass_effect) with per-frame motion inputs; the
940    /// closure is read at scene-build time, so animating tilt or highlight
941    /// does not recompose.
942    fn glass_effect_with(
943        self,
944        glass: Glass,
945        dynamics: impl Fn() -> GlassDynamics + 'static,
946    ) -> Modifier;
947}
948
949impl LiquidModifierExt for Modifier {
950    fn glass_effect(self, glass: Glass) -> Modifier {
951        self.glass_effect_with(glass, GlassDynamics::default)
952    }
953
954    fn glass_effect_with(
955        self,
956        glass: Glass,
957        dynamics: impl Fn() -> GlassDynamics + 'static,
958    ) -> Modifier {
959        let colors = crate::theme::liquid_colors();
960        let resolved = glass.resolve(&colors);
961        let shape = resolved.shape;
962
963        let mut modifier = self;
964        if resolved.shadow && resolved.clip {
965            let shadow_color = resolved.shadow_color;
966            let (radius, offset_y, spread) = (
967                resolved.shadow_radius,
968                resolved.shadow_offset_y,
969                resolved.shadow_spread,
970            );
971            modifier = modifier.drop_shadow(shape.layer_shape(), move |scope| {
972                scope.radius = radius;
973                scope.spread = spread;
974                scope.offset.y = offset_y;
975                scope.color = shadow_color;
976                scope.cutout = true;
977            });
978        }
979
980        let layer = cached_glass_layer(resolved);
981        modifier.graphics_layer(move || layer(current_density(), dynamics()))
982    }
983}
984
985struct CachedGlassLayer {
986    density: u32,
987    light: [u32; 2],
988    dynamics: GlassDynamics,
989    layer: GraphicsLayer,
990}
991
992fn cached_glass_layer(resolved: ResolvedGlass) -> impl Fn(f32, GlassDynamics) -> GraphicsLayer {
993    let cached = RefCell::new(None::<CachedGlassLayer>);
994    move |density, dynamics| {
995        let direction = glass_light_direction();
996        let light = [direction.0, direction.1].map(f32::to_bits);
997        let mut cached = cached.borrow_mut();
998        if let Some(previous) = &*cached
999            && previous.density == density.to_bits()
1000            && previous.light == light
1001            && glass_dynamics_match(&previous.dynamics, &dynamics)
1002        {
1003            return previous.layer.clone();
1004        }
1005        let render_effect = (!resolved.clip && dynamics.morph.is_some())
1006            .then(|| resolved.runtime_effect(density, &dynamics, true));
1007        let layer = GraphicsLayer {
1008            backdrop_effect: Some(resolved.runtime_effect(density, &dynamics, false)),
1009            render_effect,
1010            shape: resolved.shape.layer_shape(),
1011            clip: resolved.clip,
1012            ..Default::default()
1013        };
1014        *cached = Some(CachedGlassLayer {
1015            density: density.to_bits(),
1016            light,
1017            dynamics,
1018            layer: layer.clone(),
1019        });
1020        layer
1021    }
1022}
1023
1024fn glass_dynamics_match(a: &GlassDynamics, b: &GlassDynamics) -> bool {
1025    let bits = |d: &GlassDynamics| {
1026        let GlassDynamics {
1027            activity,
1028            resting_tint,
1029            highlight_boost,
1030            saturation_boost,
1031            tint_alpha_multiplier,
1032            morph: _,
1033            touch,
1034            press_depth,
1035        } = d;
1036        (
1037            activity.map(f32::to_bits),
1038            resting_tint.map(|Color(r, g, b, a)| [r, g, b, a].map(f32::to_bits)),
1039            [*highlight_boost, *saturation_boost].map(f32::to_bits),
1040            tint_alpha_multiplier.map(f32::to_bits),
1041            touch.map(|(x, y, strength)| [x, y, strength].map(f32::to_bits)),
1042            press_depth.map(f32::to_bits),
1043        )
1044    };
1045    bits(a) == bits(b)
1046        && match (&a.morph, &b.morph) {
1047            (None, None) => true,
1048            (Some(a), Some(b)) => glass_morphs_match(a, b),
1049            _ => false,
1050        }
1051}
1052
1053fn glass_morphs_match(a: &GlassMorph, b: &GlassMorph) -> bool {
1054    let shape_bits =
1055        |&(x, y, w, h, r): &(f32, f32, f32, f32, f32)| [x, y, w, h, r].map(f32::to_bits);
1056    let bits = |m: &GlassMorph| {
1057        let GlassMorph {
1058            node_size,
1059            primary,
1060            shapes: _,
1061            glue,
1062            wobble_amplitude,
1063            wobble_phase,
1064            bulge_amplitude,
1065            bulge_direction,
1066            ellipse_blend,
1067            deformation,
1068            zoom_anchor,
1069        } = m;
1070        (
1071            [
1072                node_size.0,
1073                node_size.1,
1074                *glue,
1075                *wobble_amplitude,
1076                *wobble_phase,
1077                *bulge_amplitude,
1078                *bulge_direction,
1079                *ellipse_blend,
1080                zoom_anchor.0,
1081                zoom_anchor.1,
1082            ]
1083            .map(f32::to_bits),
1084            shape_bits(primary),
1085            deformation
1086                .map(|GlassDeformation { axis, along }| [axis.0, axis.1, along].map(f32::to_bits)),
1087        )
1088    };
1089    bits(a) == bits(b)
1090        && a.shapes
1091            .iter()
1092            .map(shape_bits)
1093            .eq(b.shapes.iter().map(shape_bits))
1094}
1095
1096fn morph_output_support(
1097    morph: &GlassMorph,
1098    activity: f32,
1099    morph_pad: f32,
1100    shadow_reach: f32,
1101    shadow_offset: f32,
1102) -> Rect {
1103    let (px, py, pw, ph, _) = morph.primary;
1104    let (along, (ax, ay)) = morph.deformation.map_or((1.0, (1.0, 0.0)), |deformation| {
1105        (
1106            1.0 + (deformation.along() - 1.0) * activity,
1107            deformation.axis(),
1108        )
1109    });
1110    let across = 1.0 / along;
1111    let smallest_strain = along.min(across);
1112    let reach = (morph_pad + shadow_reach + 4.0) / smallest_strain;
1113    let rows = [
1114        (
1115            (along * ax * ax + across * ay * ay).abs(),
1116            ((along - across) * ax * ay).abs(),
1117        ),
1118        (
1119            ((along - across) * ax * ay).abs(),
1120            (along * ay * ay + across * ax * ax).abs(),
1121        ),
1122    ];
1123    let half = (pw * 0.5 + reach, ph * 0.5 + reach);
1124    let extent_x = rows[0].0 * half.0 + rows[0].1 * half.1;
1125    let extent_y = rows[1].0 * half.0 + rows[1].1 * half.1;
1126    let (mut left, mut top) = (px - extent_x, py - extent_y);
1127    let (mut right, mut bottom) = (px + extent_x, py + extent_y);
1128    for (sx, sy, sw, sh, _) in &morph.shapes {
1129        left = left.min(sx - sw * 0.5 - reach);
1130        top = top.min(sy - sh * 0.5 - reach);
1131        right = right.max(sx + sw * 0.5 + reach);
1132        bottom = bottom.max(sy + sh * 0.5 + reach);
1133    }
1134    Rect {
1135        x: left,
1136        y: top - shadow_offset,
1137        width: right - left,
1138        height: bottom - top + 2.0 * shadow_offset,
1139    }
1140}
1141
1142fn set_morph_uniforms(shader: &mut RuntimeShader, morph: &GlassMorph, activity: f32) {
1143    let (node_w, node_h) = morph.node_size;
1144    let (cx, cy, w, h, radius) = morph.primary;
1145    shader.set_float2(0, node_w.max(1.0), node_h.max(1.0));
1146    shader.set_float2(2, cx, cy);
1147    shader.set_float2(4, w, h);
1148    shader.set_float(6, radius);
1149    let count = morph.shapes.len().min(GlassMorph::MAX_SHAPES);
1150    shader.set_float(30, count as f32);
1151    for (index, (sx, sy, sw, sh, sr)) in morph.shapes.iter().take(count).enumerate() {
1152        let base = 36 + index * 5;
1153        shader.set_float(base, *sx);
1154        shader.set_float(base + 1, *sy);
1155        shader.set_float(base + 2, *sw);
1156        shader.set_float(base + 3, *sh);
1157        shader.set_float(base + 4, *sr);
1158    }
1159    shader.set_float(31, morph.glue);
1160    shader.set_float(32, morph.wobble_amplitude * activity);
1161    shader.set_float(33, morph.wobble_phase);
1162    shader.set_float(26, morph.bulge_amplitude * activity);
1163    shader.set_float(27, morph.bulge_direction);
1164    shader.set_float(110, morph.ellipse_blend.clamp(0.0, 1.0) * activity);
1165    if let Some(deformation) = morph.deformation {
1166        let axis = deformation.axis();
1167        let along = 1.0 + (deformation.along() - 1.0) * activity;
1168        shader.set_float2(106, axis.0, axis.1);
1169        shader.set_float(108, along);
1170        shader.set_float(109, 1.0 / along);
1171    } else {
1172        shader.set_float2(106, 1.0, 0.0);
1173        shader.set_float2(108, 1.0, 1.0);
1174    }
1175}
1176
1177#[cfg(test)]
1178mod tests {
1179    use super::*;
1180
1181    fn light_colors() -> LiquidColors {
1182        LiquidColors::light(Color::from_rgb_u8(0, 122, 255))
1183    }
1184
1185    fn terminal_shader(effect: RenderEffect) -> RuntimeShader {
1186        match effect {
1187            RenderEffect::Shader { shader } => std::sync::Arc::unwrap_or_clone(shader),
1188            RenderEffect::Chain { second, .. } => {
1189                terminal_shader(std::sync::Arc::unwrap_or_clone(second))
1190            }
1191            effect => panic!("expected runtime shader, got {effect:?}"),
1192        }
1193    }
1194
1195    #[test]
1196    fn cached_glass_layers_refresh_all_live_inputs_and_keep_clones_independent() {
1197        let resolved = Glass::regular().no_clip().resolve(&light_colors());
1198        let layer = cached_glass_layer(resolved.clone());
1199        let mut dynamics = GlassDynamics::default();
1200        let mut density = 2.0;
1201        let mut previous = None;
1202        for step in 0..13 {
1203            match step {
1204                1 => density = 3.0,
1205                2 => set_glass_light_direction((0.5, -0.5)),
1206                3 => dynamics.highlight_boost = 0.25,
1207                4 => dynamics.saturation_boost = 0.4,
1208                5 => dynamics.activity = Some(0.5),
1209                6 => dynamics.resting_tint = Some(Color::RED),
1210                7 => dynamics.tint_alpha_multiplier = Some(0.3),
1211                8 => dynamics.touch = Some((5.0, 8.0, 0.7)),
1212                9 => dynamics.press_depth = Some(0.4),
1213                10 => {
1214                    dynamics.morph = Some(GlassMorph {
1215                        node_size: (60.0, 30.0),
1216                        primary: (30.0, 15.0, 50.0, 20.0, 4.0),
1217                        shapes: vec![(10.0, 15.0, 8.0, 8.0, -1.0)],
1218                        ..Default::default()
1219                    })
1220                }
1221                11 => {
1222                    let morph = dynamics.morph.as_mut().unwrap();
1223                    morph.primary.0 += 3.0;
1224                    morph.shapes[0].0 += 2.0;
1225                    morph.deformation = Some(GlassDeformation::incompressible((1.0, 1.0), 1.3));
1226                }
1227                12 => dynamics.morph = None,
1228                _ => {}
1229            }
1230            let actual = layer(density, dynamics.clone());
1231            let expected = GraphicsLayer {
1232                backdrop_effect: Some(resolved.backdrop_effect(density, dynamics.clone())),
1233                render_effect: dynamics
1234                    .morph
1235                    .as_ref()
1236                    .map(|_| resolved.runtime_effect(density, &dynamics, true)),
1237                shape: resolved.shape.layer_shape(),
1238                clip: resolved.clip,
1239                ..Default::default()
1240            };
1241            assert_eq!(actual, expected, "live input step {step}");
1242            assert_ne!(
1243                previous.as_ref(),
1244                Some(&actual),
1245                "input change must reach the layer at step {step}"
1246            );
1247            let mut repeated = layer(density, dynamics.clone());
1248            assert_eq!(repeated, actual);
1249            repeated.backdrop_effect = None;
1250            assert_eq!(layer(density, dynamics.clone()), actual);
1251            previous = Some(actual);
1252        }
1253        set_glass_light_direction((0.0, 1.0));
1254        let replacement = cached_glass_layer(
1255            Glass::clear()
1256                .shape(LiquidShape::RoundedRect(9.0))
1257                .resolve(&light_colors()),
1258        );
1259        assert_ne!(
1260            replacement(density, dynamics.clone()),
1261            layer(density, dynamics)
1262        );
1263    }
1264
1265    #[test]
1266    fn glass_cache_input_equality_preserves_signed_zero_and_nan_bits() {
1267        let mut a = GlassDynamics {
1268            touch: Some((0.0, 1.0, 0.5)),
1269            ..Default::default()
1270        };
1271        let mut b = a.clone();
1272        b.touch.as_mut().unwrap().0 = -0.0;
1273        assert!(!glass_dynamics_match(&a, &b));
1274        a.highlight_boost = f32::NAN;
1275        assert!(glass_dynamics_match(&a, &a));
1276        a.morph = Some(GlassMorph::default());
1277        b = a.clone();
1278        b.morph.as_mut().unwrap().zoom_anchor.0 = -0.0;
1279        assert!(!glass_dynamics_match(&a, &b));
1280        b = a.clone();
1281        b.morph
1282            .as_mut()
1283            .unwrap()
1284            .shapes
1285            .push((0.0, 1.0, 2.0, 3.0, 4.0));
1286        assert!(!glass_dynamics_match(&a, &b));
1287    }
1288
1289    #[test]
1290    fn glass_light_direction_defaults_overhead_and_reaches_the_shader() {
1291        assert_eq!(glass_light_direction(), (0.0, 1.0));
1292        let resolved = Glass::regular().resolve(&light_colors());
1293        let effect = resolved.backdrop_effect(2.0, GlassDynamics::default());
1294        let shader = terminal_shader(effect);
1295        let u = shader.uniforms();
1296        assert_eq!(u[GLASS_LIGHT_DIRECTION_UNIFORM], 0.0);
1297        assert_eq!(u[GLASS_LIGHT_DIRECTION_UNIFORM + 1], 1.0);
1298
1299        set_glass_light_direction((1.0, 0.0));
1300        let effect = resolved.backdrop_effect(2.0, GlassDynamics::default());
1301        let u_rotated = terminal_shader(effect);
1302        let u_rotated = u_rotated.uniforms();
1303        assert_eq!(u_rotated[GLASS_LIGHT_DIRECTION_UNIFORM], 1.0);
1304        assert_eq!(u_rotated[GLASS_LIGHT_DIRECTION_UNIFORM + 1], 0.0);
1305        set_glass_light_direction((0.0, 1.0));
1306    }
1307
1308    #[test]
1309    fn liquid_shape_builds_matching_clip_and_layer_shapes() {
1310        for shape in [
1311            LiquidShape::Capsule,
1312            LiquidShape::Circle,
1313            LiquidShape::RoundedRect(12.0),
1314        ] {
1315            assert_eq!(shape.layer_shape(), LayerShape::Rounded(shape.clip_shape()));
1316        }
1317    }
1318
1319    #[test]
1320    fn glass_shadow_clamps_negative_radius() {
1321        let shadow = GlassShadow::new(Color::BLACK, -2.0, 3.0, -1.0);
1322        assert_eq!(shadow.radius, 0.0);
1323        assert_eq!(shadow.offset_y, 3.0);
1324        assert_eq!(shadow.spread, -1.0);
1325    }
1326
1327    #[test]
1328    fn incompressible_deformation_normalizes_axis_and_conserves_area() {
1329        let deformation = GlassDeformation::incompressible((3.0, 4.0), 1.25);
1330        assert_eq!(deformation.axis(), (0.6, 0.8));
1331        assert_eq!(deformation.along(), 1.25);
1332        assert!((deformation.along() * deformation.across() - 1.0).abs() < 1.0e-6);
1333        assert_eq!(
1334            GlassDeformation::incompressible((0.0, 0.0), 0.0).axis(),
1335            (1.0, 0.0)
1336        );
1337    }
1338
1339    #[test]
1340    fn glass_builders_clamp_physical_inputs() {
1341        let glass = Glass::lens()
1342            .shape(LiquidShape::Circle)
1343            .tint(Color::BLACK)
1344            .blur_radius(-2.0)
1345            .saturation(1.2)
1346            .refraction_depth(3.0)
1347            .refraction_depth_dp(-3.0)
1348            .refraction_curve(2.0)
1349            .dispersion(2.0)
1350            .transmission_refraction(2.0)
1351            .meniscus_absorption(2.0)
1352            .highlight(0.4)
1353            .lift(-0.2)
1354            .adaptive_frost(Color::WHITE, 2.0)
1355            .shadow(false)
1356            .no_clip();
1357        assert_eq!(glass.shape, LiquidShape::Circle);
1358        assert_eq!(glass.tint, Some(Color::BLACK));
1359        assert_eq!(glass.blur_radius, Some(-2.0));
1360        assert_eq!(glass.saturation, Some(1.2));
1361        assert_eq!(glass.refraction_depth, 2.0);
1362        assert_eq!(glass.refraction_depth_dp, Some(0.0));
1363        assert_eq!(glass.refraction_curve, 1.0);
1364        assert_eq!(glass.dispersion, 1.0);
1365        assert_eq!(glass.transmission_refraction, 1.0);
1366        assert_eq!(glass.meniscus_absorption, 1.0);
1367        assert_eq!(glass.highlight, 0.4);
1368        assert_eq!(glass.lift, Some(-0.2));
1369        assert_eq!(glass.adaptive_frost, 1.0);
1370        assert!(!glass.shadow);
1371        assert!(!glass.clip);
1372    }
1373
1374    #[test]
1375    fn material_variants_resolve_distinct_frost_levels() {
1376        let regular = Glass::regular().resolve(&light_colors());
1377        let clear = Glass::clear().resolve(&light_colors());
1378        let lens = Glass::lens().resolve(&light_colors());
1379        assert!(regular.blur_radius_dp > clear.blur_radius_dp);
1380        assert!(clear.blur_radius_dp > lens.blur_radius_dp);
1381        assert!(regular.saturation > clear.saturation);
1382        assert_eq!(lens.rim_style, 1.0);
1383        assert_eq!(regular.refraction_depth, 0.0);
1384        assert_eq!(regular.transmission_refraction, 0.0);
1385        assert_eq!(regular.refraction_curve, 0.25);
1386        assert_eq!(lens.refraction_curve, 1.0);
1387        assert_eq!(regular.dispersion, 0.0);
1388        assert_eq!(lens.dispersion, 0.30);
1389    }
1390
1391    #[test]
1392    fn neutral_surface_helpers_follow_foreground_polarity() {
1393        assert_eq!(
1394            neutral_surface_tint(Color::BLACK, 0.08, 0.10),
1395            Color::BLACK.with_alpha(0.08)
1396        );
1397        assert_eq!(
1398            neutral_surface_tint(Color::WHITE, 0.08, 0.10),
1399            Color::WHITE.with_alpha(0.10)
1400        );
1401        assert_eq!(neutral_surface_lift(Color::BLACK, 0.7, -0.3), 0.7);
1402        assert_eq!(neutral_surface_lift(Color::WHITE, 0.7, -0.3), -0.3);
1403    }
1404
1405    #[test]
1406    fn dynamic_saturation_reaches_the_material_tint() {
1407        let resting = Color::from_rgb_u8(0, 199, 208);
1408        let raised = boost_tint_saturation(resting, 0.55);
1409        assert_eq!(raised.r(), 0.0);
1410        assert!(raised.g() > resting.g());
1411        assert!(raised.b() > resting.b());
1412        assert_eq!(raised.a(), resting.a());
1413    }
1414
1415    #[test]
1416    fn template_specialization_follows_each_materials_dispersion_and_activity() {
1417        for (dispersion, activity) in [(0.6, 0.0), (0.0, 0.5), (0.3, 1.0), (0.0, 0.0)] {
1418            let resolved = Glass::regular()
1419                .dispersion(dispersion)
1420                .resolve(&light_colors());
1421            let mut shader = terminal_shader(resolved.backdrop_effect(
1422                1.0,
1423                GlassDynamics {
1424                    activity: Some(activity),
1425                    ..GlassDynamics::default()
1426                },
1427            ));
1428            cranpose_ui_graphics::specialize_liquid_glass_with_folds(&mut shader, true);
1429            assert_eq!(
1430                shader.uniforms()[GLASS_DISPERSION_UNIFORM],
1431                dispersion * activity
1432            );
1433            assert_eq!(shader.uniforms()[GLASS_ACTIVITY_UNIFORM], activity);
1434            assert_eq!(
1435                shader
1436                    .overrides()
1437                    .iter()
1438                    .any(|(name, _)| { *name == cranpose_ui_graphics::GLASS_DISPERSION_OFF_FLAG }),
1439                dispersion * activity == 0.0
1440            );
1441        }
1442    }
1443
1444    #[test]
1445    fn material_template_preserves_each_instances_optical_zoom_anchor() {
1446        let resolved = Glass::lens().resolve(&light_colors());
1447        for anchor in [(3.5, -2.25), (0.0, 0.0), (-7.0, 11.0)] {
1448            let shader = terminal_shader(resolved.backdrop_effect(
1449                1.5,
1450                GlassDynamics {
1451                    morph: Some(GlassMorph {
1452                        node_size: (120.0, 72.0),
1453                        primary: (60.0, 36.0, 96.0, 52.0, -1.0),
1454                        zoom_anchor: anchor,
1455                        ..Default::default()
1456                    }),
1457                    ..Default::default()
1458                },
1459            ));
1460            assert_eq!(
1461                &shader.uniforms()
1462                    [GLASS_OPTICAL_ZOOM_ANCHOR_UNIFORM..GLASS_OPTICAL_ZOOM_ANCHOR_UNIFORM + 2],
1463                &[anchor.0, anchor.1]
1464            );
1465        }
1466    }
1467
1468    #[test]
1469    fn material_instances_share_source_without_sharing_uniforms_or_overrides() {
1470        let resolved = Glass::regular().resolve(&light_colors());
1471        let mut first = terminal_shader(resolved.backdrop_effect(2.0, GlassDynamics::default()));
1472        let untouched = terminal_shader(resolved.backdrop_effect(1.0, GlassDynamics::default()));
1473        let unused_slot = RuntimeShader::MAX_USER_UNIFORMS - 1;
1474        first.set_float(unused_slot, 17.0);
1475        first.set_override("INSTANCE_ONLY", 1.0);
1476        let second = terminal_shader(resolved.backdrop_effect(1.0, GlassDynamics::default()));
1477        assert_eq!(second.uniforms(), untouched.uniforms());
1478        assert_eq!(second.overrides(), untouched.overrides());
1479        assert_eq!(
1480            second.uniforms().get(unused_slot).copied().unwrap_or(0.0),
1481            0.0
1482        );
1483        assert!(
1484            !second
1485                .overrides()
1486                .iter()
1487                .any(|(name, _)| *name == "INSTANCE_ONLY")
1488        );
1489        assert_eq!(first.uniforms()[unused_slot], 17.0);
1490        assert_eq!(first.uniforms()[GLASS_EFFECT_DENSITY_UNIFORM], 2.0);
1491        assert_eq!(second.uniforms()[GLASS_EFFECT_DENSITY_UNIFORM], 1.0);
1492        assert_eq!(first.source().as_ptr(), second.source().as_ptr());
1493    }
1494
1495    #[test]
1496    fn resolved_material_packs_wcksrd_and_dynamic_tint() {
1497        let resolved = Glass::lens()
1498            .refraction_depth(0.72)
1499            .refraction_depth_dp(21.0)
1500            .refraction_curve(0.8)
1501            .dispersion(0.42)
1502            .transmission_refraction(0.35)
1503            .meniscus_absorption(0.3)
1504            .blur_radius(3.0)
1505            .tint(Color::BLACK.with_alpha(0.8))
1506            .resolve(&light_colors());
1507        let effect = resolved.backdrop_effect(
1508            2.0,
1509            GlassDynamics {
1510                highlight_boost: 0.2,
1511                saturation_boost: 0.35,
1512                tint_alpha_multiplier: Some(0.25),
1513                ..Default::default()
1514            },
1515        );
1516        let RenderEffect::Chain { first, .. } = &effect else {
1517            panic!("macroscopic frost must precede the wcKSRD optical pass");
1518        };
1519        assert!(matches!(
1520            first.as_ref(),
1521            RenderEffect::Blur {
1522                radius_x: 6.0,
1523                radius_y: 6.0,
1524                ..
1525            }
1526        ));
1527        let shader = terminal_shader(effect);
1528        assert_eq!(shader.uniforms()[9], 0.72);
1529        assert_eq!(
1530            shader.uniforms()[GLASS_PHYSICAL_REFRACTION_DEPTH_UNIFORM],
1531            21.0
1532        );
1533        assert_eq!(
1534            shader.uniforms()[GLASS_PHYSICAL_REFRACTION_DEPTH_ENABLED_UNIFORM],
1535            1.0
1536        );
1537        assert_eq!(shader.uniforms()[GLASS_REFRACTION_CURVE_UNIFORM], 0.8);
1538        assert_eq!(shader.uniforms()[GLASS_DISPERSION_UNIFORM], 0.42);
1539        assert_eq!(
1540            shader.uniforms()[GLASS_TRANSMISSION_REFRACTION_UNIFORM],
1541            0.35
1542        );
1543        assert_eq!(shader.uniforms()[GLASS_MENISCUS_ABSORPTION_UNIFORM], 0.3);
1544        assert_eq!(shader.uniforms()[11], resolved.highlight + 0.2);
1545        assert_eq!(shader.uniforms()[18], resolved.saturation + 0.35);
1546        assert!((shader.uniforms()[17] - 0.2).abs() < 1.0e-6);
1547        assert_eq!(shader.uniforms()[GLASS_BLUR_RADIUS_UNIFORM], 0.0);
1548        assert_eq!(shader.uniforms()[GLASS_EFFECT_DENSITY_UNIFORM], 2.0);
1549    }
1550
1551    #[test]
1552    fn heavy_backdrop_blur_rides_the_gaussian_pass_alone() {
1553        let effect = Glass::regular()
1554            .resolve(&light_colors())
1555            .backdrop_effect(3.0, GlassDynamics::default());
1556        let RenderEffect::Chain { first, .. } = &effect else {
1557            panic!("heavy blur must ride the separable Gaussian pre-pass");
1558        };
1559        assert!(matches!(
1560            first.as_ref(),
1561            RenderEffect::Blur {
1562                radius_x: 24.0,
1563                radius_y: 24.0,
1564                ..
1565            }
1566        ));
1567        assert_eq!(
1568            terminal_shader(effect).uniforms()[GLASS_BLUR_RADIUS_UNIFORM],
1569            0.0
1570        );
1571    }
1572
1573    #[test]
1574    fn featherweight_backdrop_blur_stays_in_the_optical_tap() {
1575        let RenderEffect::Shader { shader } = Glass::regular()
1576            .blur_radius(0.5)
1577            .resolve(&light_colors())
1578            .backdrop_effect(3.0, GlassDynamics::default())
1579        else {
1580            panic!("a sub-cap blur must not build a Gaussian chain");
1581        };
1582        assert_eq!(shader.uniforms()[GLASS_BLUR_RADIUS_UNIFORM], 1.5);
1583    }
1584
1585    #[test]
1586    fn raised_tint_density_stays_premultiplied_alpha_safe() {
1587        let effect = Glass::lens()
1588            .tint(Color::WHITE.with_alpha(0.8))
1589            .resolve(&light_colors())
1590            .backdrop_effect(
1591                1.0,
1592                GlassDynamics {
1593                    tint_alpha_multiplier: Some(2.0),
1594                    ..Default::default()
1595                },
1596            );
1597        assert_eq!(terminal_shader(effect).uniforms()[17], 1.0);
1598    }
1599
1600    #[test]
1601    fn optical_activity_reaches_identity_without_removing_the_glass_geometry() {
1602        let resolved = Glass::lens()
1603            .refraction_depth(0.72)
1604            .refraction_curve(0.8)
1605            .dispersion(0.42)
1606            .blur_radius(3.0)
1607            .saturation(1.4)
1608            .highlight(0.6)
1609            .lift(0.2)
1610            .tint(Color::BLACK.with_alpha(0.8))
1611            .no_clip()
1612            .resolve(&light_colors());
1613        let morph = GlassMorph {
1614            node_size: (120.0, 72.0),
1615            primary: (60.0, 36.0, 96.0, 52.0, -1.0),
1616            wobble_amplitude: 3.0,
1617            bulge_amplitude: 4.0,
1618            deformation: Some(GlassDeformation::incompressible((1.0, 0.0), 1.25)),
1619            ..Default::default()
1620        };
1621
1622        let RenderEffect::Shader { shader } = resolved.backdrop_effect(
1623            2.0,
1624            GlassDynamics {
1625                activity: Some(0.0),
1626                morph: Some(morph),
1627                ..Default::default()
1628            },
1629        ) else {
1630            panic!("material must resolve to the shared wcKSRD shader");
1631        };
1632        let uniforms = shader.uniforms();
1633        assert_eq!(uniforms[GLASS_ACTIVITY_UNIFORM], 0.0);
1634        assert_eq!(uniforms[9], 0.0);
1635        assert_eq!(uniforms[GLASS_REFRACTION_CURVE_UNIFORM], 0.0);
1636        assert_eq!(uniforms[GLASS_DISPERSION_UNIFORM], 0.0);
1637        assert_eq!(uniforms[GLASS_TRANSMISSION_REFRACTION_UNIFORM], 0.0);
1638        assert_eq!(uniforms[11], 0.0);
1639        assert_eq!(uniforms[17], 0.0);
1640        assert_eq!(uniforms[18], 1.0);
1641        assert_eq!(uniforms[20], 0.0);
1642        assert_eq!(uniforms[21], 0.0);
1643        assert_eq!(uniforms[24], 1.0);
1644        assert_eq!(uniforms[28], 0.0);
1645        assert_eq!(uniforms[GLASS_BLUR_RADIUS_UNIFORM], 0.0);
1646        assert_eq!(uniforms[91], 0.0);
1647        assert_eq!(uniforms[102], 0.0);
1648        assert_eq!(
1649            &uniforms[GLASS_RESTING_TINT_UNIFORM..GLASS_RESTING_TINT_UNIFORM + 4],
1650            &[0.0, 0.0, 0.0, 0.0]
1651        );
1652        assert_eq!(uniforms[32], 0.0);
1653        assert_eq!(uniforms[26], 0.0);
1654        assert_eq!(&uniforms[108..110], &[1.0, 1.0]);
1655        assert_eq!(&uniforms[2..6], &[60.0, 36.0, 96.0, 52.0]);
1656    }
1657
1658    #[test]
1659    fn resting_surface_tint_survives_zero_optical_activity() {
1660        let tint = Color::BLACK.with_alpha(0.11);
1661        let RenderEffect::Shader { shader } = Glass::lens()
1662            .no_clip()
1663            .resolve(&light_colors())
1664            .backdrop_effect(
1665                1.0,
1666                GlassDynamics {
1667                    activity: Some(0.0),
1668                    resting_tint: Some(tint),
1669                    ..Default::default()
1670                },
1671            )
1672        else {
1673            panic!("resting surface must use the shared wcKSRD shader");
1674        };
1675        assert_eq!(
1676            &shader.uniforms()[GLASS_RESTING_TINT_UNIFORM..GLASS_RESTING_TINT_UNIFORM + 4],
1677            &[tint.r(), tint.g(), tint.b(), tint.a()]
1678        );
1679        assert_eq!(shader.uniforms()[GLASS_ACTIVITY_UNIFORM], 0.0);
1680    }
1681
1682    #[test]
1683    fn full_optical_activity_preserves_the_resolved_material() {
1684        let resolved = Glass::lens()
1685            .refraction_depth(0.72)
1686            .refraction_curve(0.8)
1687            .dispersion(0.42)
1688            .blur_radius(3.0)
1689            .resolve(&light_colors());
1690        let shader = terminal_shader(resolved.backdrop_effect(
1691            2.0,
1692            GlassDynamics {
1693                activity: Some(1.0),
1694                ..Default::default()
1695            },
1696        ));
1697        let uniforms = shader.uniforms();
1698        assert_eq!(uniforms[GLASS_ACTIVITY_UNIFORM], 1.0);
1699        assert_eq!(uniforms[9], 0.72);
1700        assert_eq!(
1701            uniforms[GLASS_PHYSICAL_REFRACTION_DEPTH_ENABLED_UNIFORM],
1702            0.0
1703        );
1704        assert_eq!(uniforms[GLASS_REFRACTION_CURVE_UNIFORM], 0.8);
1705        assert_eq!(uniforms[GLASS_DISPERSION_UNIFORM], 0.42);
1706        assert_eq!(uniforms[GLASS_TRANSMISSION_REFRACTION_UNIFORM], 1.0);
1707        assert_eq!(uniforms[GLASS_BLUR_RADIUS_UNIFORM], 0.0);
1708    }
1709
1710    #[test]
1711    fn morph_geometry_and_incompressible_strain_are_packed() {
1712        let deformation = GlassDeformation::incompressible((0.0, 2.0), 1.25);
1713        let morph = GlassMorph {
1714            node_size: (78.0, 59.0),
1715            primary: (39.0, 29.5, 58.0, 39.0, -1.0),
1716            shapes: vec![(70.0, 29.5, 40.0, 40.0, -1.0)],
1717            glue: 8.0,
1718            wobble_amplitude: 1.0,
1719            wobble_phase: 0.5,
1720            bulge_amplitude: 2.0,
1721            bulge_direction: 0.25,
1722            ellipse_blend: 0.3,
1723            deformation: Some(deformation),
1724            zoom_anchor: (0.0, 0.0),
1725        };
1726        let RenderEffect::Shader { shader } = Glass::lens()
1727            .no_clip()
1728            .resolve(&light_colors())
1729            .backdrop_effect(
1730                1.0,
1731                GlassDynamics {
1732                    morph: Some(morph),
1733                    ..Default::default()
1734                },
1735            )
1736        else {
1737            panic!("morph must use the shared wcKSRD shader");
1738        };
1739        let uniforms = shader.uniforms();
1740        assert_eq!(&uniforms[0..6], &[78.0, 59.0, 39.0, 29.5, 58.0, 39.0]);
1741        assert_eq!(uniforms[30], 1.0);
1742        assert_eq!(&uniforms[106..110], &[0.0, 1.0, 1.25, 0.8]);
1743        assert_eq!(uniforms[110], 0.3);
1744        assert!(shader.output_padding() > 0.0);
1745    }
1746
1747    #[test]
1748    fn optical_builders_hold_their_own_legal_range() {
1749        let glass = Glass::regular()
1750            .fold_depth(-4.0)
1751            .optical_zoom(0.5)
1752            .rim_reflection(9.0);
1753        assert_eq!(glass.fold_depth, 0.0, "a fold cannot be negative deep");
1754        assert_eq!(glass.optical_zoom, 1.0, "a lens never shrinks its face");
1755        assert_eq!(
1756            glass.rim_reflection, 2.0,
1757            "the rim tops out at twice the line"
1758        );
1759
1760        let glass = Glass::regular()
1761            .fold_depth(6.0)
1762            .optical_zoom(1.4)
1763            .rim_reflection(0.25);
1764        assert_eq!(glass.fold_depth, 6.0);
1765        assert_eq!(glass.optical_zoom, 1.4);
1766        assert_eq!(glass.rim_reflection, 0.25);
1767    }
1768
1769    #[test]
1770    fn ink_recolor_records_its_color_at_a_clamped_strength() {
1771        let red = Color::from_rgb_u8(255, 0, 0);
1772        let glass = Glass::regular().ink_recolor(red, 3.0);
1773        assert_eq!(glass.ink_recolor, Some((red, 1.0)));
1774
1775        let glass = Glass::regular().ink_recolor(red, -1.0);
1776        assert_eq!(glass.ink_recolor, Some((red, 0.0)));
1777        assert_eq!(Glass::regular().ink_recolor, None, "no recolor by default");
1778    }
1779
1780    #[test]
1781    fn a_touched_up_surface_amplifies_what_it_already_is() {
1782        let dynamics = GlassDynamics::default().touched_up(1.0, None, (10.0, 4.0));
1783        assert!(
1784            dynamics.highlight_boost > 0.0,
1785            "a pressed surface goes brighter"
1786        );
1787        assert!(dynamics.saturation_boost > 0.0, "and more saturated");
1788        assert_eq!(
1789            dynamics.touch,
1790            Some((10.0, 4.0, 1.0)),
1791            "with no finger the glow sits at the surface's own heart"
1792        );
1793        assert_eq!(
1794            dynamics.resting_tint, None,
1795            "a touch never repaints the surface with another color"
1796        );
1797    }
1798
1799    #[test]
1800    fn a_touch_glow_follows_the_finger_and_accumulates_to_full() {
1801        let dynamics = GlassDynamics::default()
1802            .touched_up(0.5, Some((3.0, 7.0)), (10.0, 4.0))
1803            .touched_up(0.8, Some((3.0, 7.0)), (10.0, 4.0));
1804        let (x, y, intensity) = dynamics.touch.expect("a pressed surface glows");
1805        assert_eq!(
1806            (x, y),
1807            (3.0, 7.0),
1808            "the glow tracks the finger, not the center"
1809        );
1810        assert_eq!(intensity, 1.0, "carried press saturates at full");
1811    }
1812
1813    #[test]
1814    fn an_unpressed_surface_is_left_exactly_as_it_was() {
1815        let resting = GlassDynamics::default();
1816        let untouched = resting
1817            .clone()
1818            .touched_up(0.0, Some((3.0, 7.0)), (10.0, 4.0));
1819        assert_eq!(untouched.highlight_boost, resting.highlight_boost);
1820        assert_eq!(untouched.saturation_boost, resting.saturation_boost);
1821        assert_eq!(untouched.touch, None, "no press, no glow");
1822    }
1823
1824    #[test]
1825    fn an_off_axis_strain_widens_the_support_by_the_affine_rows_times_the_half_extents() {
1826        let resolved = Glass::regular()
1827            .shape(LiquidShape::RoundedRect(0.0))
1828            .blur_radius(0.0)
1829            .shadow(false)
1830            .no_clip()
1831            .resolve(&light_colors());
1832        let angle = 22.5f32.to_radians();
1833        let morph = GlassMorph {
1834            node_size: (600.0, 600.0),
1835            primary: (300.0, 300.0, 200.0, 200.0, 0.0),
1836            deformation: Some(GlassDeformation::incompressible(
1837                (angle.cos(), angle.sin()),
1838                2.0,
1839            )),
1840            ..Default::default()
1841        };
1842        let RenderEffect::Shader { shader } = resolved.backdrop_effect(
1843            1.0,
1844            GlassDynamics {
1845                activity: Some(1.0),
1846                morph: Some(morph),
1847                ..Default::default()
1848            },
1849        ) else {
1850            panic!("an unblurred glass is one shader");
1851        };
1852        let support = shader
1853            .output_support()
1854            .expect("a morphing glass declares its support");
1855        let half_x = 100.0 * (2.0 * angle.cos().powi(2) + 0.5 * angle.sin().powi(2))
1856            + 100.0 * (1.5 * angle.sin() * angle.cos());
1857        assert!(
1858            (half_x - 231.066).abs() < 0.01,
1859            "the example's half extent is {half_x}"
1860        );
1861        assert!(
1862            support.x <= 300.0 - half_x && support.x + support.width >= 300.0 + half_x,
1863            "the support {support:?} must hold the strained square's x extent {half_x}"
1864        );
1865        let half_y = 100.0 * (1.5 * angle.sin() * angle.cos())
1866            + 100.0 * (2.0 * angle.sin().powi(2) + 0.5 * angle.cos().powi(2));
1867        assert!(
1868            (half_y - 125.0).abs() < 0.01,
1869            "the example's y half extent is {half_y}"
1870        );
1871        assert!(
1872            support.y <= 300.0 - half_y && support.y + support.height >= 300.0 + half_y,
1873            "the support {support:?} must hold the strained square's y extent {half_y}"
1874        );
1875    }
1876
1877    #[test]
1878    fn a_morphing_glass_declares_its_output_support_around_its_shapes_and_their_reach() {
1879        let resolved = Glass::regular()
1880            .shape(LiquidShape::RoundedRect(12.0))
1881            .blur_radius(0.0)
1882            .shadow(true)
1883            .no_clip()
1884            .resolve(&light_colors());
1885        let morph = GlassMorph {
1886            node_size: (280.0, 160.0),
1887            primary: (140.0, 80.0, 120.0, 40.0, -1.0),
1888            shapes: vec![(230.0, 80.0, 40.0, 40.0, -1.0)],
1889            glue: 20.0,
1890            wobble_amplitude: 3.0,
1891            bulge_amplitude: 8.0,
1892            deformation: Some(GlassDeformation::incompressible((1.0, 0.0), 1.25)),
1893            ..Default::default()
1894        };
1895        let dynamics = GlassDynamics {
1896            activity: Some(1.0),
1897            morph: Some(morph.clone()),
1898            ..Default::default()
1899        };
1900        let RenderEffect::Shader { shader } = resolved.backdrop_effect(2.0, dynamics) else {
1901            panic!("an unblurred glass is one shader");
1902        };
1903        let support = shader
1904            .output_support()
1905            .expect("a morphing glass declares its support");
1906        let shape_reach = (230.0 + 20.0) - (140.0 + 60.0);
1907        let shadow_reach = resolved.shadow_radius
1908            + resolved.shadow_offset_y.abs()
1909            + resolved.shadow_spread.max(0.0);
1910        let field_reach = 3.0 * 2.0 + 8.0 + shape_reach + 40.0 + shadow_reach + 4.0;
1911        let smallest_strain = 0.8;
1912        let reach = field_reach / smallest_strain;
1913        let contains = |x: f32, y: f32| {
1914            support.x <= x
1915                && support.y <= y
1916                && support.x + support.width >= x
1917                && support.y + support.height >= y
1918        };
1919        for (cx, cy) in [(-1.0, -1.0), (1.0, -1.0), (-1.0, 1.0), (1.0, 1.0)] {
1920            let corner = ((60.0 + reach) * cx, (20.0 + reach) * cy);
1921            let stretched = (140.0 + corner.0 * 1.25, 80.0 + corner.1 * 0.8);
1922            assert!(
1923                contains(stretched.0, stretched.1 - resolved.shadow_offset_y.abs())
1924                    && contains(stretched.0, stretched.1 + resolved.shadow_offset_y.abs()),
1925                "{support:?} must hold the strained, dilated corner {stretched:?}"
1926            );
1927        }
1928        assert!(
1929            contains(230.0 + 20.0 + reach, 80.0),
1930            "the glued shape's reach is inside"
1931        );
1932        assert!(shader.output_padding() >= field_reach);
1933
1934        let RenderEffect::Shader { shader } =
1935            resolved.backdrop_effect(2.0, GlassDynamics::default())
1936        else {
1937            panic!("an unblurred glass is one shader");
1938        };
1939        assert_eq!(
1940            shader.output_support(),
1941            None,
1942            "cover-mode glass fills its rect"
1943        );
1944    }
1945}