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}