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