Skip to main content

fret_core/scene/
mod.rs

1use crate::{
2    Px, SvgFit, ViewportFit,
3    geometry::{Corners, Edges, Point, Rect, Transform2D},
4    ids::{EffectId, ImageId, PathId, RenderTargetId, SvgId, TextBlobId},
5};
6use serde::{Deserialize, Serialize};
7use slotmap::Key;
8
9mod composite;
10mod fingerprint;
11mod image_object_fit;
12mod mask;
13mod paint;
14mod replay;
15mod shadow;
16mod stroke;
17mod validate;
18
19pub use composite::{BlendMode, CompositeGroupDesc};
20use fingerprint::mix_scene_op;
21pub use image_object_fit::{ImageObjectFitMapped, map_image_object_fit};
22pub use mask::Mask;
23pub use paint::{
24    ColorSpace, GradientStop, LinearGradient, MAX_STOPS, MaterialParams, Paint, PaintBindingV1,
25    PaintEvalSpaceV1, RadialGradient, SweepGradient, TileMode,
26};
27pub use shadow::{ShadowRRectFallbackSpec, shadow_rrect_fallback_quads};
28pub use stroke::{DashPatternV1, StrokeStyleV1};
29pub use validate::{SceneValidationError, SceneValidationErrorKind};
30
31#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32pub enum ImageSamplingHint {
33    /// Renderer-chosen default (typically linear filtering for UI content).
34    #[default]
35    Default,
36    Linear,
37    Nearest,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub struct DrawOrder(pub u32);
42
43// `DrawOrder` is intentionally non-semantic for compositing. Scene operation order is authoritative.
44// See `docs/adr/0081-draworder-is-non-semantic.md`.
45
46#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
47pub struct Color {
48    pub r: f32,
49    pub g: f32,
50    pub b: f32,
51    pub a: f32,
52}
53
54impl Color {
55    pub const TRANSPARENT: Self = Self {
56        r: 0.0,
57        g: 0.0,
58        b: 0.0,
59        a: 0.0,
60    };
61
62    /// Convert an sRGB `0xRRGGBB` hex color into a linear `Color` (alpha = 1.0).
63    pub fn from_srgb_hex_rgb(hex: u32) -> Self {
64        let r = ((hex >> 16) & 0xff) as u8;
65        let g = ((hex >> 8) & 0xff) as u8;
66        let b = (hex & 0xff) as u8;
67        Self {
68            r: srgb_u8_to_linear(r),
69            g: srgb_u8_to_linear(g),
70            b: srgb_u8_to_linear(b),
71            a: 1.0,
72        }
73    }
74
75    /// Convert a linear `Color` to an sRGB `0xRRGGBB` hex value (alpha ignored).
76    pub fn to_srgb_hex_rgb(self) -> u32 {
77        let r = linear_to_srgb_u8(self.r) as u32;
78        let g = linear_to_srgb_u8(self.g) as u32;
79        let b = linear_to_srgb_u8(self.b) as u32;
80        (r << 16) | (g << 8) | b
81    }
82}
83
84fn srgb_f32_to_linear(c: f32) -> f32 {
85    if c <= 0.04045 {
86        c / 12.92
87    } else {
88        ((c + 0.055) / 1.055).powf(2.4)
89    }
90}
91
92fn linear_f32_to_srgb(c: f32) -> f32 {
93    if c <= 0.0031308 {
94        12.92 * c
95    } else {
96        1.055 * c.powf(1.0 / 2.4) - 0.055
97    }
98}
99
100fn srgb_u8_to_linear(u: u8) -> f32 {
101    srgb_f32_to_linear(u as f32 / 255.0)
102}
103
104fn linear_to_srgb_u8(c: f32) -> u8 {
105    let srgb = linear_f32_to_srgb(c.clamp(0.0, 1.0)).clamp(0.0, 1.0);
106    (srgb * 255.0).round() as u8
107}
108
109/// A bounded, portable text shadow surface (v1).
110///
111/// This is intentionally minimal (single layer, no blur) so it remains viable across wasm/mobile
112/// backends. Higher-level shadow recipes (multi-layer elevation, blur, color management) remain
113/// policy in ecosystem crates.
114#[derive(Debug, Clone, Copy, PartialEq)]
115pub struct TextShadowV1 {
116    /// Baseline-origin offset in logical pixels (pre-scale-factor).
117    pub offset: Point,
118    pub color: Color,
119}
120
121impl TextShadowV1 {
122    pub const fn new(offset: Point, color: Color) -> Self {
123        Self { offset, color }
124    }
125}
126
127/// A bounded, portable text outline/stroke surface (v1).
128///
129/// This is intentionally minimal so it can be implemented deterministically across wasm/mobile
130/// backends. More advanced strategies (e.g. SDF/MSDF atlases, multi-layer outlines) remain v2+.
131#[derive(Debug, Clone, Copy, PartialEq)]
132pub struct TextOutlineV1 {
133    pub paint: PaintBindingV1,
134    /// Outline width in logical pixels (pre-scale-factor).
135    pub width_px: crate::Px,
136}
137
138impl TextOutlineV1 {
139    pub const MAX_WIDTH_PX: crate::Px = crate::Px(8.0);
140
141    pub fn sanitize(self) -> Option<Self> {
142        if !self.width_px.0.is_finite() {
143            return None;
144        }
145        let width_px = crate::Px(self.width_px.0.clamp(0.0, Self::MAX_WIDTH_PX.0));
146        if width_px.0 <= 0.0 {
147            return None;
148        }
149        Some(Self {
150            paint: self.paint.sanitize(),
151            width_px,
152        })
153    }
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub enum EffectMode {
158    /// Render children to an offscreen intermediate, then filter and composite the result.
159    FilterContent,
160    /// Sample already-rendered backdrop behind the group, filter it, then draw children on top.
161    Backdrop,
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub enum EffectQuality {
166    /// Renderer-chosen quality within budgets (ADR 0118).
167    Auto,
168    Low,
169    Medium,
170    High,
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum DitherMode {
175    Bayer4x4,
176}
177
178/// Fixed-size custom effect parameters (v1).
179///
180/// This is intentionally a small, bounded payload for capability-gated custom effects.
181#[repr(C)]
182#[derive(Debug, Clone, Copy, PartialEq)]
183pub struct EffectParamsV1 {
184    pub vec4s: [[f32; 4]; 4],
185}
186
187impl EffectParamsV1 {
188    pub const ZERO: Self = Self {
189        vec4s: [[0.0; 4]; 4],
190    };
191
192    pub fn sanitize(self) -> Self {
193        let mut out = self;
194        for v in &mut out.vec4s {
195            for x in v {
196                if !x.is_finite() {
197                    *x = 0.0;
198                }
199            }
200        }
201        out
202    }
203
204    pub fn is_finite(self) -> bool {
205        self.vec4s.iter().flatten().all(|&x| x.is_finite())
206    }
207}
208
209/// Bounded procedural noise parameters (v1).
210///
211/// This is a mechanism-level surface intended to enable authored “grain” layers that are useful
212/// for acrylic/glass recipes (e.g. subtle noise after backdrop blur) while remaining deterministic
213/// (no hidden time dependency).
214#[derive(Debug, Clone, Copy, PartialEq)]
215pub struct NoiseV1 {
216    /// Additive noise magnitude in linear space. Recommended range is ~[0, 0.1].
217    pub strength: f32,
218    /// Spatial scale for the noise field in logical pixels (pre-scale-factor).
219    pub scale_px: crate::Px,
220    /// Deterministic phase/seed value (no hidden time dependency).
221    pub phase: f32,
222}
223
224impl NoiseV1 {
225    pub const MAX_STRENGTH: f32 = 1.0;
226    pub const MIN_SCALE_PX: crate::Px = crate::Px(1.0);
227    pub const MAX_SCALE_PX: crate::Px = crate::Px(1024.0);
228
229    pub fn sanitize(self) -> Self {
230        let strength = if self.strength.is_finite() {
231            self.strength.clamp(0.0, Self::MAX_STRENGTH)
232        } else {
233            0.0
234        };
235        let scale_px = if self.scale_px.0.is_finite() {
236            crate::Px(
237                self.scale_px
238                    .0
239                    .clamp(Self::MIN_SCALE_PX.0, Self::MAX_SCALE_PX.0),
240            )
241        } else {
242            Self::MIN_SCALE_PX
243        };
244        let phase = if self.phase.is_finite() {
245            self.phase
246        } else {
247            0.0
248        };
249
250        Self {
251            strength,
252            scale_px,
253            phase,
254        }
255    }
256}
257
258/// Bounded backdrop warp parameters (v1).
259///
260/// This is a mechanism-level surface intended to enable refraction-like liquid glass effects by
261/// sampling the already-rendered backdrop with a deterministic UV displacement. Higher-level
262/// recipes (normal-map assets, interaction curves, multi-layer stacks) remain ecosystem policy.
263#[derive(Debug, Clone, Copy, PartialEq)]
264pub struct BackdropWarpV1 {
265    /// Displacement strength in logical pixels (pre-scale-factor).
266    pub strength_px: crate::Px,
267    /// Spatial scale for the warp field in logical pixels.
268    pub scale_px: crate::Px,
269    /// Deterministic phase/seed value (no hidden time dependency).
270    pub phase: f32,
271    /// Optional chromatic aberration magnitude in logical pixels.
272    pub chromatic_aberration_px: crate::Px,
273    pub kind: BackdropWarpKindV1,
274}
275
276impl BackdropWarpV1 {
277    pub const MAX_STRENGTH_PX: crate::Px = crate::Px(24.0);
278    pub const MIN_SCALE_PX: crate::Px = crate::Px(1.0);
279    pub const MAX_SCALE_PX: crate::Px = crate::Px(1024.0);
280    pub const MAX_CHROMATIC_ABERRATION_PX: crate::Px = crate::Px(8.0);
281
282    pub fn sanitize(self) -> Self {
283        let strength_px = if self.strength_px.0.is_finite() {
284            crate::Px(self.strength_px.0.clamp(0.0, Self::MAX_STRENGTH_PX.0))
285        } else {
286            crate::Px(0.0)
287        };
288
289        let scale_px = if self.scale_px.0.is_finite() {
290            crate::Px(
291                self.scale_px
292                    .0
293                    .clamp(Self::MIN_SCALE_PX.0, Self::MAX_SCALE_PX.0),
294            )
295        } else {
296            Self::MIN_SCALE_PX
297        };
298
299        let phase = if self.phase.is_finite() {
300            self.phase
301        } else {
302            0.0
303        };
304
305        let chromatic_aberration_px = if self.chromatic_aberration_px.0.is_finite() {
306            crate::Px(
307                self.chromatic_aberration_px
308                    .0
309                    .clamp(0.0, Self::MAX_CHROMATIC_ABERRATION_PX.0),
310            )
311        } else {
312            crate::Px(0.0)
313        };
314
315        Self {
316            strength_px,
317            scale_px,
318            phase,
319            chromatic_aberration_px,
320            kind: self.kind,
321        }
322    }
323}
324
325#[derive(Debug, Clone, Copy, PartialEq, Eq)]
326pub enum WarpMapEncodingV1 {
327    /// Decode displacement from RG in [0, 1] mapped to [-1, 1].
328    ///
329    /// This is a good default for authored displacement maps.
330    RgSigned,
331    /// Decode a normal from RGB in [0, 1] mapped to [-1, 1], and use XY as displacement.
332    ///
333    /// This is convenient when the warp field is stored as a normal map.
334    NormalRgb,
335}
336
337#[derive(Debug, Clone, Copy, PartialEq)]
338pub enum BackdropWarpFieldV2 {
339    /// Use the procedural v1 warp field (`BackdropWarpV1`).
340    Procedural,
341    /// Use an image-driven displacement/normal map as the warp field.
342    ImageDisplacementMap {
343        image: ImageId,
344        uv: UvRect,
345        sampling: ImageSamplingHint,
346        encoding: WarpMapEncodingV1,
347    },
348}
349
350#[derive(Debug, Clone, Copy, PartialEq)]
351pub struct BackdropWarpV2 {
352    /// The v1 parameters are retained as the portable base (and deterministic fallback).
353    pub base: BackdropWarpV1,
354    pub field: BackdropWarpFieldV2,
355}
356
357impl BackdropWarpV2 {
358    pub fn sanitize(self) -> Self {
359        let base = self.base.sanitize();
360        let field = match self.field {
361            BackdropWarpFieldV2::Procedural => BackdropWarpFieldV2::Procedural,
362            BackdropWarpFieldV2::ImageDisplacementMap {
363                image,
364                uv,
365                sampling,
366                encoding,
367            } => {
368                let uv = if uv.u0.is_finite()
369                    && uv.v0.is_finite()
370                    && uv.u1.is_finite()
371                    && uv.v1.is_finite()
372                {
373                    uv
374                } else {
375                    UvRect::FULL
376                };
377                BackdropWarpFieldV2::ImageDisplacementMap {
378                    image,
379                    uv,
380                    sampling,
381                    encoding,
382                }
383            }
384        };
385
386        Self { base, field }
387    }
388}
389
390#[derive(Debug, Clone, Copy, PartialEq, Eq)]
391pub enum BackdropWarpKindV1 {
392    Wave,
393    /// Reserved for a lens-like warp in a future v1.x/v2. Renderers may treat this as `Wave`.
394    LensReserved,
395}
396
397/// Bounded drop shadow parameters (v1).
398///
399/// This is a mechanism-level, blur-based shadow surface intended for general UI content (cards,
400/// popovers, overlays). It is explicitly bounded and deterministic so it remains viable on
401/// wasm/WebGPU and mobile GPUs.
402#[derive(Debug, Clone, Copy, PartialEq)]
403pub struct DropShadowV1 {
404    /// Shadow offset in logical pixels (pre-scale-factor).
405    pub offset_px: Point,
406    /// Blur radius in logical pixels (pre-scale-factor).
407    pub blur_radius_px: crate::Px,
408    /// Downsample hint (1–4). Renderers may degrade deterministically under budgets.
409    pub downsample: u32,
410    /// Solid shadow color (unpremultiplied RGBA in [0, 1]).
411    pub color: Color,
412}
413
414impl DropShadowV1 {
415    pub const MAX_BLUR_RADIUS_PX: crate::Px = crate::Px(64.0);
416
417    pub fn sanitize(self) -> Self {
418        let offset_px = Point::new(
419            crate::Px(if self.offset_px.x.0.is_finite() {
420                self.offset_px.x.0
421            } else {
422                0.0
423            }),
424            crate::Px(if self.offset_px.y.0.is_finite() {
425                self.offset_px.y.0
426            } else {
427                0.0
428            }),
429        );
430        let blur_radius_px = if self.blur_radius_px.0.is_finite() {
431            crate::Px(self.blur_radius_px.0.clamp(0.0, Self::MAX_BLUR_RADIUS_PX.0))
432        } else {
433            crate::Px(0.0)
434        };
435        let downsample = self.downsample.clamp(1, 4);
436        let color = Color {
437            r: if self.color.r.is_finite() {
438                self.color.r.clamp(0.0, 1.0)
439            } else {
440                0.0
441            },
442            g: if self.color.g.is_finite() {
443                self.color.g.clamp(0.0, 1.0)
444            } else {
445                0.0
446            },
447            b: if self.color.b.is_finite() {
448                self.color.b.clamp(0.0, 1.0)
449            } else {
450                0.0
451            },
452            a: if self.color.a.is_finite() {
453                self.color.a.clamp(0.0, 1.0)
454            } else {
455                0.0
456            },
457        };
458
459        Self {
460            offset_px,
461            blur_radius_px,
462            downsample,
463            color,
464        }
465    }
466}
467
468/// Optional user image input for bounded custom effects (v2).
469///
470/// This is intentionally small and portable: it references an `ImageId` registered through the
471/// existing image service and uses the existing `ImageSamplingHint` vocabulary.
472#[derive(Debug, Clone, Copy, PartialEq)]
473pub struct CustomEffectImageInputV1 {
474    pub image: ImageId,
475    pub uv: UvRect,
476    pub sampling: ImageSamplingHint,
477}
478
479impl CustomEffectImageInputV1 {
480    pub const fn new(image: ImageId) -> Self {
481        Self {
482            image,
483            uv: UvRect::FULL,
484            sampling: ImageSamplingHint::Default,
485        }
486    }
487}
488
489/// Custom effect source selection for CustomV3.
490///
491/// This stays bounded and deterministic: callers can request a distinct `src_raw` source and an
492/// optional bounded pyramid, but backends may degrade by aliasing sources under budgets or
493/// unsupported capabilities.
494#[derive(Debug, Clone, Copy, PartialEq, Default)]
495pub struct CustomEffectSourcesV3 {
496    pub want_raw: bool,
497    pub pyramid: Option<CustomEffectPyramidRequestV1>,
498}
499
500/// Bounded request for a renderer-owned blur pyramid derived from `src_raw` (v3).
501#[derive(Debug, Clone, Copy, PartialEq)]
502pub struct CustomEffectPyramidRequestV1 {
503    pub max_levels: u8,
504    pub max_radius_px: crate::Px,
505}
506
507#[derive(Debug, Clone, Copy, PartialEq)]
508pub enum EffectStep {
509    GaussianBlur {
510        radius_px: crate::Px,
511        downsample: u32,
512    },
513    DropShadowV1(DropShadowV1),
514    BackdropWarpV1(BackdropWarpV1),
515    BackdropWarpV2(BackdropWarpV2),
516    NoiseV1(NoiseV1),
517    ColorAdjust {
518        saturation: f32,
519        brightness: f32,
520        contrast: f32,
521    },
522    ColorMatrix {
523        m: [f32; 20],
524    },
525    AlphaThreshold {
526        cutoff: f32,
527        soft: f32,
528    },
529    Pixelate {
530        scale: u32,
531    },
532    Dither {
533        mode: DitherMode,
534    },
535    CustomV1 {
536        id: EffectId,
537        params: EffectParamsV1,
538        /// Maximum sampling offset (in logical px) that the custom effect may use when reading
539        /// from its source texture.
540        ///
541        /// This is a bounded contract input used by renderers to deterministically allocate
542        /// enough context ("padding") for effect chains (e.g. blur -> custom refraction) without
543        /// introducing edge artifacts near clipped/scissored bounds.
544        ///
545        /// Backends may clamp or degrade behavior under tight budgets.
546        max_sample_offset_px: crate::Px,
547    },
548    CustomV2 {
549        id: EffectId,
550        params: EffectParamsV1,
551        /// Maximum sampling offset (in logical px) that the custom effect may use when reading
552        /// from its source texture.
553        ///
554        /// This preserves the deterministic chain padding story from v1.
555        max_sample_offset_px: crate::Px,
556        /// Optional user-provided image input (v2 ceiling bump).
557        input_image: Option<CustomEffectImageInputV1>,
558    },
559    CustomV3 {
560        id: EffectId,
561        params: EffectParamsV1,
562        /// Maximum sampling offset (in logical px) that the custom effect may use when reading
563        /// from its source textures.
564        ///
565        /// This preserves the deterministic chain padding story from v1/v2.
566        max_sample_offset_px: crate::Px,
567        /// Optional user-provided image input 0 (v2-compatible).
568        user0: Option<CustomEffectImageInputV1>,
569        /// Optional user-provided image input 1 (v3 ceiling bump).
570        user1: Option<CustomEffectImageInputV1>,
571        /// Renderer-provided sources request (raw + optional pyramid).
572        sources: CustomEffectSourcesV3,
573    },
574}
575
576impl EffectStep {
577    pub fn sanitize(self) -> Self {
578        match self {
579            EffectStep::ColorMatrix { mut m } => {
580                for v in &mut m {
581                    if !v.is_finite() {
582                        *v = 0.0;
583                    }
584                }
585                EffectStep::ColorMatrix { m }
586            }
587            EffectStep::BackdropWarpV1(w) => EffectStep::BackdropWarpV1(w.sanitize()),
588            EffectStep::BackdropWarpV2(w) => EffectStep::BackdropWarpV2(w.sanitize()),
589            EffectStep::DropShadowV1(s) => EffectStep::DropShadowV1(s.sanitize()),
590            EffectStep::NoiseV1(n) => EffectStep::NoiseV1(n.sanitize()),
591            EffectStep::CustomV1 {
592                id,
593                params,
594                max_sample_offset_px,
595            } => EffectStep::CustomV1 {
596                id,
597                params: params.sanitize(),
598                max_sample_offset_px: if max_sample_offset_px.0.is_finite() {
599                    crate::Px(max_sample_offset_px.0.max(0.0))
600                } else {
601                    crate::Px(0.0)
602                },
603            },
604            EffectStep::CustomV2 {
605                id,
606                params,
607                max_sample_offset_px,
608                input_image,
609            } => EffectStep::CustomV2 {
610                id,
611                params: params.sanitize(),
612                max_sample_offset_px: if max_sample_offset_px.0.is_finite() {
613                    crate::Px(max_sample_offset_px.0.max(0.0))
614                } else {
615                    crate::Px(0.0)
616                },
617                input_image: input_image.map(|mut input| {
618                    if !input.uv.u0.is_finite() {
619                        input.uv.u0 = 0.0;
620                    }
621                    if !input.uv.v0.is_finite() {
622                        input.uv.v0 = 0.0;
623                    }
624                    if !input.uv.u1.is_finite() {
625                        input.uv.u1 = 1.0;
626                    }
627                    if !input.uv.v1.is_finite() {
628                        input.uv.v1 = 1.0;
629                    }
630                    input
631                }),
632            },
633            EffectStep::CustomV3 {
634                id,
635                params,
636                max_sample_offset_px,
637                user0,
638                user1,
639                mut sources,
640            } => {
641                let sanitize_input = |input: Option<CustomEffectImageInputV1>| {
642                    input.map(|mut input| {
643                        if !input.uv.u0.is_finite() {
644                            input.uv.u0 = 0.0;
645                        }
646                        if !input.uv.v0.is_finite() {
647                            input.uv.v0 = 0.0;
648                        }
649                        if !input.uv.u1.is_finite() {
650                            input.uv.u1 = 1.0;
651                        }
652                        if !input.uv.v1.is_finite() {
653                            input.uv.v1 = 1.0;
654                        }
655                        input
656                    })
657                };
658
659                sources.pyramid = sources.pyramid.map(|req| {
660                    let max_levels = req.max_levels.clamp(1, 7);
661                    let max_radius_px = if req.max_radius_px.0.is_finite() {
662                        crate::Px(req.max_radius_px.0.max(0.0))
663                    } else {
664                        crate::Px(0.0)
665                    };
666                    CustomEffectPyramidRequestV1 {
667                        max_levels,
668                        max_radius_px,
669                    }
670                });
671
672                EffectStep::CustomV3 {
673                    id,
674                    params: params.sanitize(),
675                    max_sample_offset_px: if max_sample_offset_px.0.is_finite() {
676                        crate::Px(max_sample_offset_px.0.max(0.0))
677                    } else {
678                        crate::Px(0.0)
679                    },
680                    user0: sanitize_input(user0),
681                    user1: sanitize_input(user1),
682                    sources,
683                }
684            }
685            EffectStep::AlphaThreshold { cutoff, soft } => EffectStep::AlphaThreshold {
686                cutoff: if cutoff.is_finite() { cutoff } else { 0.0 },
687                soft: if soft.is_finite() { soft.max(0.0) } else { 0.0 },
688            },
689            other => other,
690        }
691    }
692}
693
694#[derive(Debug, Clone, Copy, PartialEq)]
695pub struct EffectChain {
696    steps: [Option<EffectStep>; 4],
697}
698
699impl EffectChain {
700    pub const MAX_STEPS: usize = 4;
701    pub const EMPTY: Self = Self {
702        steps: [None, None, None, None],
703    };
704
705    pub fn from_steps(steps: &[EffectStep]) -> Self {
706        assert!(
707            steps.len() <= Self::MAX_STEPS,
708            "EffectChain supports up to {} steps",
709            Self::MAX_STEPS
710        );
711        let mut out = Self::EMPTY;
712        for (idx, step) in steps.iter().copied().enumerate() {
713            out.steps[idx] = Some(step);
714        }
715        out
716    }
717
718    pub fn is_empty(&self) -> bool {
719        self.steps.iter().all(|s| s.is_none())
720    }
721
722    pub fn iter(&self) -> impl Iterator<Item = EffectStep> + '_ {
723        self.steps.iter().copied().flatten()
724    }
725
726    pub fn sanitize(self) -> Self {
727        let mut out = self;
728        for step in &mut out.steps {
729            *step = step.map(EffectStep::sanitize);
730        }
731        out
732    }
733}
734
735impl Default for EffectChain {
736    fn default() -> Self {
737        Self::EMPTY
738    }
739}
740
741#[derive(Debug, Default, Clone)]
742pub struct SceneRecording {
743    ops: Vec<SceneOp>,
744    fingerprint: u64,
745    #[cfg(debug_assertions)]
746    storage_swapped_since_clear: bool,
747}
748
749pub type Scene = SceneRecording;
750
751impl SceneRecording {
752    pub fn clear(&mut self) {
753        self.ops.clear();
754        self.fingerprint = 0;
755        #[cfg(debug_assertions)]
756        {
757            self.storage_swapped_since_clear = false;
758        }
759    }
760
761    pub fn push(&mut self, op: SceneOp) {
762        // Clamp quad corner radii to the local rect size (CSS-style effective border radius).
763        //
764        // Browsers constrain border radii to half the corresponding box dimension. Many shadcn
765        // components use `rounded-full`, which maps to an arbitrarily large radius that becomes
766        // `min(width, height) / 2` in practice. Keeping this normalization at the scene layer makes
767        // renderer backends and scripted tests agree on the effective shape.
768        let op = match op {
769            SceneOp::Quad {
770                order,
771                rect,
772                background,
773                border,
774                border_paint,
775                mut corner_radii,
776            } => {
777                let max = rect.size.width.0.min(rect.size.height.0) * 0.5;
778                let max = if max.is_finite() { max.max(0.0) } else { 0.0 };
779                corner_radii.top_left = Px(corner_radii.top_left.0.min(max));
780                corner_radii.top_right = Px(corner_radii.top_right.0.min(max));
781                corner_radii.bottom_left = Px(corner_radii.bottom_left.0.min(max));
782                corner_radii.bottom_right = Px(corner_radii.bottom_right.0.min(max));
783
784                SceneOp::Quad {
785                    order,
786                    rect,
787                    background: background.sanitize(),
788                    border,
789                    border_paint: border_paint.sanitize(),
790                    corner_radii,
791                }
792            }
793            SceneOp::ShadowRRect {
794                order,
795                rect,
796                mut corner_radii,
797                offset,
798                spread,
799                blur_radius,
800                color,
801            } => {
802                let max = rect.size.width.0.min(rect.size.height.0) * 0.5;
803                let max = if max.is_finite() { max.max(0.0) } else { 0.0 };
804                corner_radii.top_left = Px(corner_radii.top_left.0.max(0.0).min(max));
805                corner_radii.top_right = Px(corner_radii.top_right.0.max(0.0).min(max));
806                corner_radii.bottom_left = Px(corner_radii.bottom_left.0.max(0.0).min(max));
807                corner_radii.bottom_right = Px(corner_radii.bottom_right.0.max(0.0).min(max));
808
809                let blur_radius = if blur_radius.0.is_finite() {
810                    Px(blur_radius
811                        .0
812                        .clamp(0.0, SHADOW_RRECT_V1_MAX_BLUR_RADIUS_PX.0))
813                } else {
814                    Px(0.0)
815                };
816
817                SceneOp::ShadowRRect {
818                    order,
819                    rect,
820                    corner_radii,
821                    offset,
822                    spread,
823                    blur_radius,
824                    color,
825                }
826            }
827            SceneOp::PushEffect {
828                bounds,
829                mode,
830                chain,
831                quality,
832            } => SceneOp::PushEffect {
833                bounds,
834                mode,
835                chain: chain.sanitize(),
836                quality,
837            },
838            SceneOp::Text {
839                order,
840                origin,
841                text,
842                paint,
843                outline,
844                shadow,
845            } => SceneOp::Text {
846                order,
847                origin,
848                text,
849                paint: paint.sanitize(),
850                outline: outline.and_then(|o| o.sanitize()),
851                shadow,
852            },
853            other => other,
854        };
855
856        self.fingerprint = mix_scene_op(self.fingerprint, op);
857        self.ops.push(op);
858    }
859
860    pub fn with_transform<T>(
861        &mut self,
862        transform: Transform2D,
863        f: impl FnOnce(&mut Self) -> T,
864    ) -> T {
865        self.push(SceneOp::PushTransform { transform });
866        let out = f(self);
867        self.push(SceneOp::PopTransform);
868        out
869    }
870
871    pub fn with_opacity<T>(&mut self, opacity: f32, f: impl FnOnce(&mut Self) -> T) -> T {
872        self.push(SceneOp::PushOpacity { opacity });
873        let out = f(self);
874        self.push(SceneOp::PopOpacity);
875        out
876    }
877
878    pub fn with_layer<T>(&mut self, layer: u32, f: impl FnOnce(&mut Self) -> T) -> T {
879        self.push(SceneOp::PushLayer { layer });
880        let out = f(self);
881        self.push(SceneOp::PopLayer);
882        out
883    }
884
885    pub fn with_clip_rect<T>(&mut self, rect: Rect, f: impl FnOnce(&mut Self) -> T) -> T {
886        self.push(SceneOp::PushClipRect { rect });
887        let out = f(self);
888        self.push(SceneOp::PopClip);
889        out
890    }
891
892    pub fn with_clip_rrect<T>(
893        &mut self,
894        rect: Rect,
895        corner_radii: Corners,
896        f: impl FnOnce(&mut Self) -> T,
897    ) -> T {
898        self.push(SceneOp::PushClipRRect { rect, corner_radii });
899        let out = f(self);
900        self.push(SceneOp::PopClip);
901        out
902    }
903
904    pub fn with_clip_path<T>(
905        &mut self,
906        bounds: Rect,
907        origin: Point,
908        path: PathId,
909        f: impl FnOnce(&mut Self) -> T,
910    ) -> T {
911        self.push(SceneOp::PushClipPath {
912            bounds,
913            origin,
914            path,
915        });
916        let out = f(self);
917        self.push(SceneOp::PopClip);
918        out
919    }
920
921    pub fn with_mask<T>(&mut self, bounds: Rect, mask: Mask, f: impl FnOnce(&mut Self) -> T) -> T {
922        self.push(SceneOp::PushMask { bounds, mask });
923        let out = f(self);
924        self.push(SceneOp::PopMask);
925        out
926    }
927
928    pub fn with_effect<T>(
929        &mut self,
930        bounds: Rect,
931        mode: EffectMode,
932        chain: EffectChain,
933        quality: EffectQuality,
934        f: impl FnOnce(&mut Self) -> T,
935    ) -> T {
936        self.push(SceneOp::PushEffect {
937            bounds,
938            mode,
939            chain,
940            quality,
941        });
942        let out = f(self);
943        self.push(SceneOp::PopEffect);
944        out
945    }
946
947    pub fn with_composite_group<T>(
948        &mut self,
949        desc: CompositeGroupDesc,
950        f: impl FnOnce(&mut Self) -> T,
951    ) -> T {
952        self.push(SceneOp::PushCompositeGroup { desc });
953        let out = f(self);
954        self.push(SceneOp::PopCompositeGroup);
955        out
956    }
957
958    pub fn with_backdrop_source_group_v1<T>(
959        &mut self,
960        bounds: Rect,
961        pyramid: Option<CustomEffectPyramidRequestV1>,
962        quality: EffectQuality,
963        f: impl FnOnce(&mut Self) -> T,
964    ) -> T {
965        self.push(SceneOp::PushBackdropSourceGroupV1 {
966            bounds,
967            pyramid,
968            quality,
969        });
970        let out = f(self);
971        self.push(SceneOp::PopBackdropSourceGroup);
972        out
973    }
974
975    pub fn ops(&self) -> &[SceneOp] {
976        &self.ops
977    }
978
979    pub fn ops_len(&self) -> usize {
980        self.ops.len()
981    }
982
983    /// Swap the internal op storage with an external buffer.
984    ///
985    /// This is a performance-oriented API used by subsystems like the UI paint cache to "take"
986    /// the previous frame's ops without copying.
987    ///
988    /// In debug builds, this asserts if called more than once without an intervening `clear()`,
989    /// because repeated swaps typically indicate multiple paint-cache ingestions from the same scene.
990    pub fn swap_storage(&mut self, other_ops: &mut Vec<SceneOp>, other_fingerprint: &mut u64) {
991        #[cfg(debug_assertions)]
992        debug_assert!(
993            !self.storage_swapped_since_clear,
994            "Scene::swap_storage() was called more than once without an intervening Scene::clear(); \
995this is not supported because swap_storage() is destructive and typically indicates multiple paint-cache ingestions"
996        );
997        std::mem::swap(&mut self.ops, other_ops);
998        std::mem::swap(&mut self.fingerprint, other_fingerprint);
999        #[cfg(debug_assertions)]
1000        {
1001            self.storage_swapped_since_clear = true;
1002        }
1003    }
1004
1005    pub fn fingerprint(&self) -> u64 {
1006        self.fingerprint
1007    }
1008}
1009
1010#[derive(Debug, Clone, Copy)]
1011pub enum SceneOp {
1012    PushTransform {
1013        transform: Transform2D,
1014    },
1015    PopTransform,
1016
1017    /// Opacity multiplier applied to subsequent draw ops.
1018    ///
1019    /// The opacity stack composes multiplicatively (parent * child).
1020    PushOpacity {
1021        opacity: f32,
1022    },
1023    PopOpacity,
1024
1025    /// Reserved layer stack marker (ADR 0019).
1026    PushLayer {
1027        layer: u32,
1028    },
1029    PopLayer,
1030
1031    PushClipRect {
1032        rect: Rect,
1033    },
1034    PushClipRRect {
1035        rect: Rect,
1036        corner_radii: Corners,
1037    },
1038    /// Push a path-based clip entry (clip-path).
1039    ///
1040    /// `bounds` is a computation bound (not an implicit clip) used to bound GPU work and enable
1041    /// deterministic budgeting/degradation. The clip geometry is given by a prepared path handle.
1042    ///
1043    /// v1 note: renderers may implement this via an offscreen intermediate + mask composite.
1044    PushClipPath {
1045        bounds: Rect,
1046        origin: Point,
1047        path: PathId,
1048    },
1049    PopClip,
1050
1051    PushMask {
1052        /// Computation bounds (not an implicit clip), see ADR 0239.
1053        bounds: Rect,
1054        mask: Mask,
1055    },
1056    PopMask,
1057
1058    PushEffect {
1059        /// Computation bounds (not an implicit clip), see ADR 0117.
1060        bounds: Rect,
1061        mode: EffectMode,
1062        chain: EffectChain,
1063        quality: EffectQuality,
1064    },
1065    PopEffect,
1066
1067    /// Backdrop source group (v1): a mechanism-level scope that enables renderers to share a raw
1068    /// backdrop snapshot (and optional pyramid) across multiple CustomV3 “liquid glass” surfaces.
1069    ///
1070    /// `bounds` are computation bounds (not an implicit clip). `pyramid` is an optional bounded
1071    /// request for a shared renderer-owned blur pyramid derived from the group snapshot.
1072    ///
1073    /// See ADR 0302.
1074    PushBackdropSourceGroupV1 {
1075        /// Computation bounds (not an implicit clip).
1076        bounds: Rect,
1077        /// Optional bounded pyramid request shared by the group (upper bound).
1078        pyramid: Option<CustomEffectPyramidRequestV1>,
1079        /// Quality hint used for deterministic budgeting/degradation.
1080        quality: EffectQuality,
1081    },
1082    PopBackdropSourceGroup,
1083
1084    PushCompositeGroup {
1085        desc: CompositeGroupDesc,
1086    },
1087    PopCompositeGroup,
1088
1089    Quad {
1090        order: DrawOrder,
1091        rect: Rect,
1092        background: PaintBindingV1,
1093        border: Edges,
1094        border_paint: PaintBindingV1,
1095        corner_radii: Corners,
1096    },
1097
1098    StrokeRRect {
1099        order: DrawOrder,
1100        rect: Rect,
1101        stroke: Edges,
1102        stroke_paint: PaintBindingV1,
1103        corner_radii: Corners,
1104        style: StrokeStyleV1,
1105    },
1106
1107    /// Draw a single rounded-rect box shadow layer.
1108    ///
1109    /// This is a first-class geometric shadow primitive for container chrome. Unlike
1110    /// `EffectStep::DropShadowV1`, it is not content-derived and does not require a FilterContent
1111    /// intermediate.
1112    ShadowRRect {
1113        order: DrawOrder,
1114        rect: Rect,
1115        corner_radii: Corners,
1116        offset: Point,
1117        spread: Px,
1118        blur_radius: Px,
1119        color: Color,
1120    },
1121
1122    Image {
1123        order: DrawOrder,
1124        rect: Rect,
1125        image: ImageId,
1126        fit: ViewportFit,
1127        sampling: ImageSamplingHint,
1128        opacity: f32,
1129    },
1130
1131    ImageRegion {
1132        order: DrawOrder,
1133        rect: Rect,
1134        image: ImageId,
1135        uv: UvRect,
1136        sampling: ImageSamplingHint,
1137        opacity: f32,
1138    },
1139
1140    /// Draw an alpha mask image tinted with a solid color.
1141    ///
1142    /// The referenced `image` is expected to store coverage in the red channel (e.g. `R8Unorm`).
1143    MaskImage {
1144        order: DrawOrder,
1145        rect: Rect,
1146        image: ImageId,
1147        uv: UvRect,
1148        sampling: ImageSamplingHint,
1149        color: Color,
1150        opacity: f32,
1151    },
1152
1153    /// Draw an SVG as a monochrome icon: rasterize to an alpha mask, then tint with a solid color.
1154    SvgMaskIcon {
1155        order: DrawOrder,
1156        rect: Rect,
1157        svg: SvgId,
1158        fit: SvgFit,
1159        color: Color,
1160        opacity: f32,
1161    },
1162
1163    /// Draw an SVG as an RGBA image: rasterize and upload as an image texture.
1164    SvgImage {
1165        order: DrawOrder,
1166        rect: Rect,
1167        svg: SvgId,
1168        fit: SvgFit,
1169        opacity: f32,
1170    },
1171
1172    Text {
1173        order: DrawOrder,
1174        origin: Point,
1175        text: TextBlobId,
1176        paint: PaintBindingV1,
1177        outline: Option<TextOutlineV1>,
1178        shadow: Option<TextShadowV1>,
1179    },
1180
1181    Path {
1182        order: DrawOrder,
1183        origin: Point,
1184        path: PathId,
1185        paint: PaintBindingV1,
1186    },
1187
1188    ViewportSurface {
1189        order: DrawOrder,
1190        rect: Rect,
1191        target: RenderTargetId,
1192        opacity: f32,
1193    },
1194}
1195
1196#[derive(Debug, Clone, Copy, PartialEq)]
1197pub struct UvRect {
1198    pub u0: f32,
1199    pub v0: f32,
1200    pub u1: f32,
1201    pub v1: f32,
1202}
1203
1204impl UvRect {
1205    pub const FULL: Self = Self {
1206        u0: 0.0,
1207        v0: 0.0,
1208        u1: 1.0,
1209        v1: 1.0,
1210    };
1211}
1212
1213pub const SHADOW_RRECT_V1_MAX_BLUR_RADIUS_PX: crate::Px = crate::Px(64.0);
1214
1215#[cfg(test)]
1216mod tests {
1217    use super::*;
1218    use crate::geometry::{Px, Size};
1219
1220    #[test]
1221    fn replay_ops_translated_wraps_in_transform_stack() {
1222        let ops = [SceneOp::Quad {
1223            order: DrawOrder(0),
1224            rect: Rect::new(Point::new(Px(0.0), Px(0.0)), Size::new(Px(10.0), Px(10.0))),
1225            background: Paint::Solid(Color::TRANSPARENT).into(),
1226            border: Edges::all(Px(0.0)),
1227            border_paint: Paint::Solid(Color::TRANSPARENT).into(),
1228            corner_radii: Corners::all(Px(0.0)),
1229        }];
1230
1231        let mut scene = Scene::default();
1232        scene.replay_ops_translated(&ops, Point::new(Px(2.0), Px(3.0)));
1233
1234        assert_eq!(scene.ops_len(), 3);
1235        assert!(matches!(scene.ops()[0], SceneOp::PushTransform { .. }));
1236        assert!(matches!(scene.ops()[1], SceneOp::Quad { .. }));
1237        assert!(matches!(scene.ops()[2], SceneOp::PopTransform));
1238    }
1239
1240    #[test]
1241    fn validate_rejects_transform_underflow() {
1242        let mut scene = Scene::default();
1243        scene.push(SceneOp::PopTransform);
1244        assert!(matches!(
1245            scene.validate(),
1246            Err(SceneValidationError {
1247                kind: SceneValidationErrorKind::TransformUnderflow,
1248                ..
1249            })
1250        ));
1251    }
1252
1253    #[test]
1254    fn validate_rejects_unbalanced_clip_stack() {
1255        let mut scene = Scene::default();
1256        scene.push(SceneOp::PushClipRect {
1257            rect: Rect::new(Point::new(Px(0.0), Px(0.0)), Size::new(Px(1.0), Px(1.0))),
1258        });
1259        assert!(matches!(
1260            scene.validate(),
1261            Err(SceneValidationError {
1262                kind: SceneValidationErrorKind::UnbalancedClipStack { remaining: 1 },
1263                ..
1264            })
1265        ));
1266    }
1267
1268    #[test]
1269    fn validate_rejects_effect_underflow() {
1270        let mut scene = Scene::default();
1271        scene.push(SceneOp::PopEffect);
1272        assert!(matches!(
1273            scene.validate(),
1274            Err(SceneValidationError {
1275                kind: SceneValidationErrorKind::EffectUnderflow,
1276                ..
1277            })
1278        ));
1279    }
1280
1281    #[test]
1282    fn validate_rejects_unbalanced_effect_stack() {
1283        let mut scene = Scene::default();
1284        scene.push(SceneOp::PushEffect {
1285            bounds: Rect::new(Point::new(Px(0.0), Px(0.0)), Size::new(Px(1.0), Px(1.0))),
1286            mode: EffectMode::Backdrop,
1287            chain: EffectChain::from_steps(&[EffectStep::GaussianBlur {
1288                radius_px: Px(2.0),
1289                downsample: 2,
1290            }]),
1291            quality: EffectQuality::Auto,
1292        });
1293        assert!(matches!(
1294            scene.validate(),
1295            Err(SceneValidationError {
1296                kind: SceneValidationErrorKind::UnbalancedEffectStack { remaining: 1 },
1297                ..
1298            })
1299        ));
1300    }
1301
1302    #[test]
1303    fn validate_rejects_opacity_underflow() {
1304        let mut scene = Scene::default();
1305        scene.push(SceneOp::PopOpacity);
1306        assert!(matches!(
1307            scene.validate(),
1308            Err(SceneValidationError {
1309                kind: SceneValidationErrorKind::OpacityUnderflow,
1310                ..
1311            })
1312        ));
1313    }
1314
1315    #[test]
1316    fn validate_rejects_layer_underflow() {
1317        let mut scene = Scene::default();
1318        scene.push(SceneOp::PopLayer);
1319        assert!(matches!(
1320            scene.validate(),
1321            Err(SceneValidationError {
1322                kind: SceneValidationErrorKind::LayerUnderflow,
1323                ..
1324            })
1325        ));
1326    }
1327
1328    #[test]
1329    fn validate_rejects_clip_underflow() {
1330        let mut scene = Scene::default();
1331        scene.push(SceneOp::PopClip);
1332        assert!(matches!(
1333            scene.validate(),
1334            Err(SceneValidationError {
1335                kind: SceneValidationErrorKind::ClipUnderflow,
1336                ..
1337            })
1338        ));
1339    }
1340
1341    #[test]
1342    fn validate_rejects_nonfinite_opacity() {
1343        let mut scene = Scene::default();
1344        scene.push(SceneOp::PushOpacity { opacity: f32::NAN });
1345        assert!(matches!(
1346            scene.validate(),
1347            Err(SceneValidationError {
1348                kind: SceneValidationErrorKind::NonFiniteOpacity,
1349                ..
1350            })
1351        ));
1352    }
1353
1354    #[test]
1355    fn validate_rejects_nonfinite_transform() {
1356        let mut scene = Scene::default();
1357        scene.push(SceneOp::PushTransform {
1358            transform: Transform2D {
1359                a: f32::NAN,
1360                ..Transform2D::IDENTITY
1361            },
1362        });
1363        assert!(matches!(
1364            scene.validate(),
1365            Err(SceneValidationError {
1366                kind: SceneValidationErrorKind::NonFiniteTransform,
1367                ..
1368            })
1369        ));
1370    }
1371
1372    #[test]
1373    fn validate_rejects_nonfinite_draw_op_data() {
1374        let mut scene = Scene::default();
1375        scene.push(SceneOp::Quad {
1376            order: DrawOrder(0),
1377            rect: Rect::new(
1378                Point::new(Px(f32::NAN), Px(0.0)),
1379                Size::new(Px(10.0), Px(10.0)),
1380            ),
1381            background: Paint::Solid(Color::TRANSPARENT).into(),
1382            border: Edges::all(Px(0.0)),
1383            border_paint: Paint::Solid(Color::TRANSPARENT).into(),
1384            corner_radii: Corners::all(Px(0.0)),
1385        });
1386        assert!(matches!(
1387            scene.validate(),
1388            Err(SceneValidationError {
1389                kind: SceneValidationErrorKind::NonFiniteOpData,
1390                ..
1391            })
1392        ));
1393    }
1394
1395    #[test]
1396    fn push_shadow_rrect_clamps_blur_and_preserves_base_corner_radii() {
1397        let mut scene = Scene::default();
1398        scene.push(SceneOp::ShadowRRect {
1399            order: DrawOrder(0),
1400            rect: Rect::new(Point::new(Px(0.0), Px(0.0)), Size::new(Px(20.0), Px(12.0))),
1401            corner_radii: Corners::all(Px(9999.0)),
1402            offset: Point::new(Px(0.0), Px(4.0)),
1403            spread: Px(-4.0),
1404            blur_radius: Px(4096.0),
1405            color: Color::TRANSPARENT,
1406        });
1407
1408        let SceneOp::ShadowRRect {
1409            corner_radii,
1410            blur_radius,
1411            ..
1412        } = scene.ops()[0]
1413        else {
1414            panic!("expected shadow rrect");
1415        };
1416
1417        assert_eq!(blur_radius, SHADOW_RRECT_V1_MAX_BLUR_RADIUS_PX);
1418        assert_eq!(corner_radii, Corners::all(Px(6.0)));
1419    }
1420
1421    #[test]
1422    fn validate_rejects_nonfinite_shadow_rrect_data() {
1423        let mut scene = Scene::default();
1424        scene.push(SceneOp::ShadowRRect {
1425            order: DrawOrder(0),
1426            rect: Rect::new(Point::new(Px(0.0), Px(0.0)), Size::new(Px(10.0), Px(10.0))),
1427            corner_radii: Corners::all(Px(4.0)),
1428            offset: Point::new(Px(f32::NAN), Px(0.0)),
1429            spread: Px(0.0),
1430            blur_radius: Px(8.0),
1431            color: Color::TRANSPARENT,
1432        });
1433
1434        assert!(matches!(
1435            scene.validate(),
1436            Err(SceneValidationError {
1437                kind: SceneValidationErrorKind::NonFiniteOpData,
1438                ..
1439            })
1440        ));
1441    }
1442
1443    #[test]
1444    fn validate_rejects_unbalanced_opacity_stack() {
1445        let mut scene = Scene::default();
1446        scene.push(SceneOp::PushOpacity { opacity: 0.5 });
1447        assert!(matches!(
1448            scene.validate(),
1449            Err(SceneValidationError {
1450                kind: SceneValidationErrorKind::UnbalancedOpacityStack { remaining: 1 },
1451                ..
1452            })
1453        ));
1454    }
1455
1456    #[test]
1457    fn validate_rejects_unbalanced_layer_stack() {
1458        let mut scene = Scene::default();
1459        scene.push(SceneOp::PushLayer { layer: 1 });
1460        assert!(matches!(
1461            scene.validate(),
1462            Err(SceneValidationError {
1463                kind: SceneValidationErrorKind::UnbalancedLayerStack { remaining: 1 },
1464                ..
1465            })
1466        ));
1467    }
1468}