Skip to main content

bevy_react/layer/render/
clip.rs

1//! Render-world half of clip-independent layer capture: the extract-window
2//! clip swap, and the composite quad's rect clamp.
3//!
4//! **The swap.** Stock `bevy_ui_render` extraction copies each node's
5//! [`CalculatedClip`] onto its extracted items, and the prepare stage bakes
6//! that clip into vertices — including inside our capture passes. To keep
7//! ancestor clips out of captures without forking any extractor (several,
8//! like the gradient one, live in private modules), the members' clips are
9//! swapped for their interior values (`crate::layer::clip::LayerClips`) for
10//! **exactly the duration of `ExtractSchedule`**: [`swap_interior_clips_in`]
11//! runs before every stock UI extraction set and [`swap_interior_clips_out`]
12//! restores the originals after them. The main world is exclusively borrowed
13//! for the whole window (that is what `ResMut<MainWorld>` means), so no
14//! main-world system — picking, focus, `bevy_ui` itself — can ever observe a
15//! swapped value.
16//!
17//! Swap mechanics: values are overwritten in place (never inserted/removed —
18//! a member whose interior clip is `Some` provably carries a real
19//! `CalculatedClip`, so there is no archetype churn), "unclipped" is an
20//! all-infinite rect (idiomatic — `bevy_ui` itself uses ±∞ clip axes for
21//! `Visible` overflow), and both directions bypass change detection (the net
22//! effect within a frame is identity; `update_clipping`'s own `!=` guard must
23//! not see churn). Known gap: a `UiMaterial` extractor is generic and
24//! unordered relative to the swap window — unused in this repo.
25//!
26//! **The quad clamp.** With captures unclipped, the composite quad applies
27//! the ancestor clipping instead: [`clip_quad`] clamps the quad's corners to
28//! the layer's quad clip and shifts UVs proportionally (the same
29//! `positions_diff` technique stock prepare uses on item vertices). A fully
30//! clipped-away layer yields `None` — its quad simply isn't batched.
31
32use bevy::math::{Rect, UVec2, Vec2};
33use bevy::prelude::*;
34use bevy::render::MainWorld;
35use bevy::ui::CalculatedClip;
36
37use crate::layer::clip::LayerClips;
38
39/// "No clip" as a value: bevy_ui expresses visible-overflow axes as ±∞, and
40/// the prepare-stage clip math is a no-op against it.
41pub const UNCLIPPED: Rect = Rect {
42    min: Vec2::splat(f32::NEG_INFINITY),
43    max: Vec2::splat(f32::INFINITY),
44};
45
46/// The originals stashed by [`swap_interior_clips_in`], restored by
47/// [`swap_interior_clips_out`]. Render-world resource; drained every frame.
48#[derive(Resource, Default)]
49pub struct SwappedClips(pub Vec<(Entity, Rect)>);
50
51/// `ExtractSchedule`, before all stock UI extraction sets: overwrite each
52/// promoted-subtree member's [`CalculatedClip`] with its interior clip.
53pub fn swap_interior_clips_in(mut main_world: ResMut<MainWorld>, mut stash: ResMut<SwappedClips>) {
54    swap_in(&mut main_world, &mut stash.0);
55}
56
57/// `ExtractSchedule`, after all stock UI extraction sets: restore the
58/// originals so the main world resumes with true inherited clips in place.
59pub fn swap_interior_clips_out(mut main_world: ResMut<MainWorld>, mut stash: ResMut<SwappedClips>) {
60    swap_out(&mut main_world, &mut stash.0);
61}
62
63/// Core of [`swap_interior_clips_in`], factored on `&mut World` for tests.
64pub fn swap_in(world: &mut World, stash: &mut Vec<(Entity, Rect)>) {
65    stash.clear();
66    if world.get_resource::<LayerClips>().is_none() {
67        return; // Main app without the plugin's resources: nothing to swap.
68    }
69    world.resource_scope(|world, clips: Mut<LayerClips>| {
70        for (&entity, &interior) in clips.interior.iter() {
71            // A member without the component needs no swap: its interior clip
72            // is provably `None` too (interior clip sources are a subset of
73            // the real cascade's). A despawned entity is a stale map row.
74            let Ok(mut e) = world.get_entity_mut(entity) else {
75                continue;
76            };
77            let Some(mut clip) = e.get_mut::<CalculatedClip>() else {
78                continue;
79            };
80            let clip = clip.bypass_change_detection();
81            stash.push((entity, clip.clip));
82            clip.clip = interior.unwrap_or(UNCLIPPED);
83        }
84    });
85}
86
87/// Core of [`swap_interior_clips_out`]: restore every stashed original.
88pub fn swap_out(world: &mut World, stash: &mut Vec<(Entity, Rect)>) {
89    for (entity, original) in stash.drain(..) {
90        if let Ok(mut e) = world.get_entity_mut(entity)
91            && let Some(mut clip) = e.get_mut::<CalculatedClip>()
92        {
93            clip.bypass_change_detection().clip = original;
94        }
95    }
96}
97
98/// A composite quad clamped to its clip: screen-space corners plus the
99/// matching capture-texture UV window.
100#[derive(Debug, Clone, Copy, PartialEq)]
101pub struct ClippedQuad {
102    pub pos_min: Vec2,
103    pub pos_max: Vec2,
104    pub uv_min: Vec2,
105    pub uv_max: Vec2,
106}
107
108/// Clamp a layer's composite quad (`min`, `size` — the capture rect) to its
109/// quad clip. `None` clip = the full quad; an empty or degenerate
110/// intersection returns `None` (draw nothing). UVs shift proportionally on
111/// clamped sides only, so the visible part of the capture stays put on
112/// screen.
113pub fn clip_quad(min: Vec2, size: UVec2, clip: Option<Rect>) -> Option<ClippedQuad> {
114    let size = size.as_vec2();
115    let max = min + size;
116    let (pos_min, pos_max) = match clip {
117        None => (min, max),
118        Some(c) => (min.max(c.min), max.min(c.max)),
119    };
120    if pos_min.x >= pos_max.x || pos_min.y >= pos_max.y {
121        return None;
122    }
123    Some(ClippedQuad {
124        pos_min,
125        pos_max,
126        uv_min: (pos_min - min) / size,
127        uv_max: (pos_max - min) / size,
128    })
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    /// `clip_quad` truth table: full quad without a clip, proportional UV
136    /// shifts on clamped sides, identity for a containing clip, `None` for
137    /// empty/degenerate intersections.
138    #[test]
139    fn clip_quad_table() {
140        let min = Vec2::new(10.0, 20.0);
141        let size = UVec2::new(100, 50);
142
143        // No clip: full quad, full UV window.
144        let full = clip_quad(min, size, None).expect("unclipped quad");
145        assert_eq!(full.pos_min, min);
146        assert_eq!(full.pos_max, Vec2::new(110.0, 70.0));
147        assert_eq!(full.uv_min, Vec2::ZERO);
148        assert_eq!(full.uv_max, Vec2::ONE);
149
150        // Left half clipped away: UV window starts at 0.5 horizontally.
151        let q = clip_quad(min, size, Some(Rect::new(60.0, 0.0, 300.0, 300.0)))
152            .expect("partial overlap");
153        assert_eq!(q.pos_min, Vec2::new(60.0, 20.0));
154        assert_eq!(q.pos_max, Vec2::new(110.0, 70.0));
155        assert_eq!(q.uv_min, Vec2::new(0.5, 0.0));
156        assert_eq!(q.uv_max, Vec2::ONE);
157
158        // Bottom 40% clipped away: UV max shrinks to 0.6 vertically.
159        let q =
160            clip_quad(min, size, Some(Rect::new(0.0, 0.0, 300.0, 50.0))).expect("partial overlap");
161        assert_eq!(q.pos_min, min);
162        assert_eq!(q.pos_max, Vec2::new(110.0, 50.0));
163        assert_eq!(q.uv_min, Vec2::ZERO);
164        assert_eq!(q.uv_max, Vec2::new(1.0, 0.6));
165
166        // Containing clip: identity.
167        let q =
168            clip_quad(min, size, Some(Rect::new(0.0, 0.0, 500.0, 500.0))).expect("containing clip");
169        assert_eq!(q, full);
170
171        // Disjoint clip: nothing to draw.
172        assert_eq!(
173            clip_quad(min, size, Some(Rect::new(200.0, 0.0, 300.0, 300.0))),
174            None
175        );
176        // Degenerate touch (shared edge): still nothing.
177        assert_eq!(
178            clip_quad(min, size, Some(Rect::new(110.0, 0.0, 300.0, 300.0))),
179            None
180        );
181        // Empty clip rect (Display::None subtree): nothing.
182        assert_eq!(clip_quad(min, size, Some(Rect::default())), None);
183    }
184
185    /// The swap overwrites members' `CalculatedClip` with interior values
186    /// (infinite rect for "unclipped inside the capture"), stashes originals,
187    /// skips entities without the component, and restores exactly.
188    #[test]
189    fn swap_roundtrip() {
190        let mut world = World::new();
191        let real = Rect::new(0.0, 0.0, 100.0, 100.0);
192        let inner = Rect::new(10.0, 10.0, 50.0, 50.0);
193
194        // Member with a real clip and an interior Some: swapped to interior.
195        let a = world.spawn(CalculatedClip { clip: real }).id();
196        // Member with a real clip but interior None: swapped to UNCLIPPED.
197        let b = world.spawn(CalculatedClip { clip: real }).id();
198        // Member with no CalculatedClip (interior necessarily None): skipped.
199        let c = world.spawn_empty().id();
200        // Non-member with a real clip: untouched.
201        let outside = world.spawn(CalculatedClip { clip: real }).id();
202        // A stale map entry for a despawned entity: skipped gracefully.
203        let dead = world.spawn_empty().id();
204        world.despawn(dead);
205
206        let mut clips = LayerClips::default();
207        clips.interior.insert(a, Some(inner));
208        clips.interior.insert(b, None);
209        clips.interior.insert(c, None);
210        clips.interior.insert(dead, Some(inner));
211        world.insert_resource(clips);
212
213        let mut stash = Vec::new();
214        swap_in(&mut world, &mut stash);
215        assert_eq!(world.get::<CalculatedClip>(a).unwrap().clip, inner);
216        assert_eq!(world.get::<CalculatedClip>(b).unwrap().clip, UNCLIPPED);
217        assert!(world.get::<CalculatedClip>(c).is_none());
218        assert_eq!(world.get::<CalculatedClip>(outside).unwrap().clip, real);
219        assert_eq!(stash.len(), 2, "only really-swapped members are stashed");
220
221        swap_out(&mut world, &mut stash);
222        assert_eq!(world.get::<CalculatedClip>(a).unwrap().clip, real);
223        assert_eq!(world.get::<CalculatedClip>(b).unwrap().clip, real);
224        assert!(stash.is_empty(), "stash drains on restore");
225    }
226
227    /// A second frame's swap starts from a clean stash even if the previous
228    /// restore was somehow skipped (defensive: swap_in clears).
229    #[test]
230    fn swap_in_clears_previous_stash() {
231        let mut world = World::new();
232        world.insert_resource(LayerClips::default());
233        let mut stash = vec![(Entity::PLACEHOLDER, UNCLIPPED)];
234        swap_in(&mut world, &mut stash);
235        assert!(stash.is_empty());
236    }
237}