bevy_react/anchor.rs
1//! World-anchored UI overlays (the `<anchor>` element).
2//!
3//! An anchored element is an ordinary screen-space `bevy_ui` node, but each frame
4//! its on-screen position is recomputed by projecting a target entity's world
5//! position (plus an optional offset) through the UI camera. That is how floating
6//! labels, nameplates, and health bars track a 3D entity while staying flat,
7//! fully interactive overlays — no render-to-texture, no second camera, no
8//! synthetic-pointer picking (clicks ride the normal `Interaction` path).
9
10use bevy::prelude::*;
11use bevy::ui::{IsDefaultUiCamera, UiGlobalTransform, UiTransform};
12use serde::Deserialize;
13
14/// The wire form of an `<anchor>`'s `anchor` prop: the Bevy entity to follow
15/// (as `Entity::to_bits()`), an optional world-space offset, and optional
16/// distance-based scaling. Pure-serde, like the rest of [`crate::protocol`].
17#[derive(Debug, Clone, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct Anchor {
20 /// The target entity's `Entity::to_bits()` value, sent from React. Carried as
21 /// `f64` because `op_flush`'s serde_v8 can't decode a struct `u64` field from a
22 /// JS number or BigInt; lossless for realistic ids (well under 2^53).
23 pub entity: f64,
24 /// World-space offset added to the target's translation before projecting.
25 #[serde(default)]
26 pub offset: Option<[f32; 3]>,
27 /// When set, the overlay scales with camera distance (else stays at scale 1).
28 #[serde(default)]
29 pub scale: Option<AnchorScaling>,
30}
31
32/// Distance-based scaling config for an anchored overlay. The applied scale is
33/// `clamp(1 + factor * (base_distance / distance - 1), min, max)`, so the overlay
34/// renders at scale 1 when the camera is exactly `base_distance` away, grows as it
35/// gets closer, and shrinks farther out — bounded by `min`/`max`.
36#[derive(Debug, Clone, Copy, Deserialize)]
37#[serde(rename_all = "camelCase")]
38pub struct AnchorScaling {
39 /// Lower bound on the applied scale.
40 pub min: f32,
41 /// Upper bound on the applied scale.
42 pub max: f32,
43 /// Scaling strength: `0` disables scaling (always 1), `1` is true perspective
44 /// (apparent size halves at twice `base_distance`), `2` scales twice as fast.
45 pub factor: f32,
46 /// Camera distance at which the overlay renders at scale 1.
47 pub base_distance: f32,
48}
49
50impl AnchorScaling {
51 /// Validate a JS-supplied config once at apply time so the per-frame math
52 /// can't panic or emit a non-finite scale: any non-finite field disables
53 /// scaling (`None`) with a warning, and reversed `min`/`max` bounds are
54 /// swapped (a reversed pair would panic `f32::clamp` every frame).
55 pub(crate) fn sanitized(self) -> Option<Self> {
56 if ![self.min, self.max, self.factor, self.base_distance]
57 .iter()
58 .all(|v| v.is_finite())
59 {
60 warn!("non-finite anchor scale config {self:?}; disabling distance scaling");
61 return None;
62 }
63 if self.min > self.max {
64 warn!(
65 "anchor scale min {} > max {}; swapping the bounds",
66 self.min, self.max
67 );
68 return Some(Self {
69 min: self.max,
70 max: self.min,
71 ..self
72 });
73 }
74 Some(self)
75 }
76}
77
78/// Distance-based scale for a [sanitized](AnchorScaling::sanitized) config: `1`
79/// when the camera is exactly `base_distance` away, growing closer / shrinking
80/// farther, pinned to `min..=max`. At `dist == 0` the ratio is `inf`, which
81/// `clamp` pins to `max` (closest → largest); a NaN product (`factor == 0` ×
82/// `inf`) resolves to `max` for the same reason.
83fn distance_scale(c: &AnchorScaling, dist: f32) -> f32 {
84 let raw = 1.0 + c.factor * (c.base_distance / dist - 1.0);
85 if raw.is_nan() {
86 c.max
87 } else {
88 raw.clamp(c.min, c.max)
89 }
90}
91
92/// Marker for the dedicated overlay container that every [`Anchored`] node is
93/// reparented under. Spawned once at startup as a zero-size, absolutely-positioned
94/// child of the UI root at the window origin, so anchored overlays live in their own
95/// hierarchy and never contribute to an app container's flex layout or scrollable
96/// `content_size`. See [`position_anchored_nodes`].
97#[derive(Component, Debug, Clone, Copy)]
98pub struct AnchorLayer;
99
100/// Component stamped (by the main reconciler) on any `<anchor>` element. Carries the
101/// followed entity, world-space offset, and optional distance scaling. Requires
102/// `Visibility` so the system can hide the overlay when its anchor is behind the
103/// camera, and `UiTransform` so it can apply the distance scale.
104#[derive(Component, Debug, Clone)]
105#[require(Visibility, UiTransform)]
106pub struct Anchored {
107 /// The entity whose world position this overlay follows.
108 pub target: Entity,
109 /// World-space offset added to the target's translation before projecting.
110 pub offset: Vec3,
111 /// Distance-based scaling, or `None` to keep the overlay at scale 1.
112 pub scale: Option<AnchorScaling>,
113}
114
115/// Reposition every [`Anchored`] node each frame: project its target's world
116/// position through the UI camera and write the result into the node's
117/// `left`/`top`, centered on the anchor point. Hides the overlay until it has been
118/// laid out (so it never flashes uncentered on spawn), and when the target has
119/// despawned or its anchor point is behind the camera / off-screen.
120///
121/// Each anchored node is also reparented under the shared [`AnchorLayer`] so it lives
122/// in its own hierarchy: an off-screen anchor's large `left`/`top` then never inflates
123/// the scrollable `content_size` of whatever app container it was declared in. The
124/// reparent self-heals if a React reorder ever moves the node back.
125///
126/// Registered in `Update` ordered after the op drain so it overrides this frame's
127/// static style. A no-op when no anchored nodes exist.
128#[allow(clippy::type_complexity)]
129pub fn position_anchored_nodes(
130 mut commands: Commands,
131 default_cam: Query<(&Camera, &GlobalTransform), With<IsDefaultUiCamera>>,
132 other_cam: Query<(&Camera, &GlobalTransform), Without<IsDefaultUiCamera>>,
133 layer: Query<Entity, With<AnchorLayer>>,
134 targets: Query<&GlobalTransform>,
135 ui_nodes: Query<(&ComputedNode, &UiGlobalTransform)>,
136 mut anchored: Query<(
137 Entity,
138 &Anchored,
139 Option<&ChildOf>,
140 &mut Node,
141 &mut Visibility,
142 &mut UiTransform,
143 )>,
144) {
145 // Project through the default UI camera; if none is marked, fall back to any
146 // camera (the host app's UI camera may carry no marker).
147 let Some((cam, cam_tf)) = default_cam
148 .iter()
149 .next()
150 .or_else(|| other_cam.iter().next())
151 else {
152 return;
153 };
154
155 // The overlay container every anchored node is reparented under.
156 let Ok(layer_entity) = layer.single() else {
157 return;
158 };
159 // Anchored nodes position relative to the layer's box, so subtract its
160 // actual top-left. Usually that's the window origin (absolute `left`/`top`
161 // 0 under the full-window root), but devtools' reserve-space mode insets
162 // the root's margin, shifting the layer with it. The layer is 0×0, so its
163 // transform translation IS its top-left (physical → logical). Pre-first-
164 // layout the translation is zero — identical to the old `Vec2::ZERO`
165 // constant — and a margin change (dock toggle / live panel resize) lags
166 // here by one frame, which is accepted.
167 let parent_top_left = ui_nodes
168 .get(layer_entity)
169 .map(|(c, t)| t.translation * c.inverse_scale_factor())
170 .unwrap_or(Vec2::ZERO);
171
172 for (entity, anchor, child_of, mut node, mut visibility, mut transform) in &mut anchored {
173 // Move the overlay into the shared anchor layer (once; self-heals on reorder) so
174 // it can't affect its declared parent's flex layout or scroll range. Done before
175 // the layout-readiness guards so it happens even while the node waits to be laid
176 // out below.
177 if child_of.map(|c| c.parent()) != Some(layer_entity) {
178 commands.entity(entity).insert(ChildOf(layer_entity));
179 }
180
181 // Always absolute, so a hidden overlay never takes flex-flow space in its
182 // parent (e.g. while it waits to be positioned below).
183 node.position_type = PositionType::Absolute;
184
185 // Center the overlay on the anchor using its own laid-out size. On the frame
186 // it spawns, `bevy_ui` layout hasn't produced a size yet (it runs later, in
187 // `PostUpdate`) and the target's transform may not have propagated — so stay
188 // hidden one frame rather than flash uncentered at a stale position. By the
189 // next frame the size is real and the transforms have settled.
190 let Ok((computed, _)) = ui_nodes.get(entity) else {
191 set_visibility(&mut visibility, Visibility::Hidden);
192 continue;
193 };
194 if computed.size().x <= 0.0 {
195 set_visibility(&mut visibility, Visibility::Hidden);
196 continue;
197 }
198
199 // The target may have despawned (or not exist yet): hide until it returns.
200 let Ok(target_tf) = targets.get(anchor.target) else {
201 set_visibility(&mut visibility, Visibility::Hidden);
202 continue;
203 };
204
205 let world = target_tf.translation() + anchor.offset;
206 let Ok(viewport) = cam.world_to_viewport(cam_tf, world) else {
207 // Behind the camera / outside the viewport: hide rather than clamp.
208 set_visibility(&mut visibility, Visibility::Hidden);
209 continue;
210 };
211
212 // Distance-based scaling (applied via `UiTransform`, which scales about the
213 // node center, so the overlay stays centered on its anchor). `None` → 1.
214 let scale = match &anchor.scale {
215 Some(c) => distance_scale(c, world.distance(cam_tf.translation())),
216 None => 1.0,
217 };
218 if transform.scale != Vec2::splat(scale) {
219 transform.scale = Vec2::splat(scale);
220 }
221
222 // `world_to_viewport` is in logical pixels, but `bevy_ui` positions an
223 // absolute node relative to its parent's box — so subtract the anchor layer's
224 // top-left (computed once above). Also center this node on the anchor using its
225 // own size.
226 let half = computed.size() * computed.inverse_scale_factor() / 2.0;
227 let local = viewport - parent_top_left - half;
228
229 node.left = Val::Px(local.x);
230 node.top = Val::Px(local.y);
231 set_visibility(&mut visibility, Visibility::Inherited);
232 }
233}
234
235/// Assign `visibility` only when it actually changes, so we don't trip change
236/// detection (and re-propagate visibility) every frame for a stationary overlay.
237fn set_visibility(visibility: &mut Mut<Visibility>, next: Visibility) {
238 if **visibility != next {
239 **visibility = next;
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::{AnchorScaling, distance_scale};
246 use crate::protocol::props::Props;
247
248 fn scaling(min: f32, max: f32, factor: f32, base_distance: f32) -> AnchorScaling {
249 AnchorScaling {
250 min,
251 max,
252 factor,
253 base_distance,
254 }
255 }
256
257 /// Reversed bounds would panic `f32::clamp` every frame; `sanitized` swaps
258 /// them instead.
259 #[test]
260 fn sanitize_swaps_reversed_bounds() {
261 let s = scaling(2.0, 0.4, 1.0, 24.0).sanitized().expect("kept");
262 assert_eq!((s.min, s.max), (0.4, 2.0));
263 // An already-valid config passes through unchanged.
264 let s = scaling(0.4, 2.0, 1.0, 24.0).sanitized().expect("kept");
265 assert_eq!((s.min, s.max), (0.4, 2.0));
266 }
267
268 /// Any non-finite field disables scaling entirely (NaN bounds would panic
269 /// `f32::clamp`; a NaN factor/base_distance would produce a NaN scale).
270 #[test]
271 fn sanitize_rejects_non_finite_fields() {
272 for bad in [
273 scaling(f32::NAN, 2.0, 1.0, 24.0),
274 scaling(0.4, f32::NAN, 1.0, 24.0),
275 scaling(0.4, 2.0, f32::NAN, 24.0),
276 scaling(0.4, 2.0, 1.0, f32::NAN),
277 scaling(0.4, f32::INFINITY, 1.0, 24.0),
278 ] {
279 assert!(bad.sanitized().is_none(), "kept {bad:?}");
280 }
281 }
282
283 /// `dist == 0` (camera exactly on the anchor) must stay finite: the `inf`
284 /// ratio pins to `max`, including the `factor == 0` case whose `0 * inf`
285 /// product is NaN.
286 #[test]
287 fn distance_scale_is_finite_at_zero_distance() {
288 let c = scaling(0.4, 2.0, 1.0, 24.0);
289 assert_eq!(distance_scale(&c, 0.0), 2.0);
290 let flat = scaling(0.4, 2.0, 0.0, 24.0);
291 assert_eq!(distance_scale(&flat, 0.0), 2.0);
292 // Normal case: at exactly base_distance the scale is 1.
293 assert_eq!(distance_scale(&c, 24.0), 1.0);
294 }
295
296 #[test]
297 fn props_deserialize_anchor() {
298 // entity bits 2^32 + 1 = index 1, generation 1 (a realistic value).
299 let props: Props = serde_json::from_value(serde_json::json!({
300 "anchor": {
301 "entity": 4294967297u64,
302 "offset": [0.0, 1.0, 0.0],
303 "scale": { "min": 0.4, "max": 2.0, "factor": 1.0, "baseDistance": 24.0 }
304 }
305 }))
306 .unwrap();
307 let anchor = props.anchor.expect("anchor present");
308 assert_eq!(anchor.entity as u64, 4_294_967_297);
309 assert_eq!(anchor.offset, Some([0.0, 1.0, 0.0]));
310 let scale = anchor.scale.expect("scale present");
311 assert_eq!(
312 (scale.min, scale.max, scale.factor, scale.base_distance),
313 (0.4, 2.0, 1.0, 24.0)
314 );
315 }
316
317 #[test]
318 fn anchor_offset_defaults_to_none() {
319 let props: Props = serde_json::from_value(serde_json::json!({
320 "anchor": { "entity": 1u64 }
321 }))
322 .unwrap();
323 assert_eq!(props.anchor.unwrap().offset, None);
324 }
325
326 #[test]
327 fn anchored_node_is_reparented_under_the_layer() {
328 use super::{AnchorLayer, Anchored, position_anchored_nodes};
329 use bevy::ecs::system::RunSystemOnce;
330 use bevy::prelude::*;
331 use bevy::ui::{ComputedNode, IsDefaultUiCamera, UiGlobalTransform};
332
333 let mut world = World::new();
334
335 // A default UI camera so the system gets past its camera guard.
336 world.spawn((
337 Camera::default(),
338 GlobalTransform::default(),
339 IsDefaultUiCamera,
340 ));
341
342 // The shared overlay layer (carries the components the layer query reads).
343 let layer = world
344 .spawn((
345 AnchorLayer,
346 ComputedNode::default(),
347 UiGlobalTransform::default(),
348 ))
349 .id();
350
351 // Some unrelated container the overlay was "declared" under in the React tree.
352 let other_parent = world.spawn(Node::default()).id();
353
354 // An anchored node parented under `other_parent` (not the layer). It has no
355 // `ComputedNode`, so it stays hidden — but the reparent runs first regardless.
356 let target = world.spawn(GlobalTransform::default()).id();
357 let badge = world
358 .spawn((
359 Node::default(),
360 Anchored {
361 target,
362 offset: Vec3::ZERO,
363 scale: None,
364 },
365 ChildOf(other_parent),
366 ))
367 .id();
368
369 world.run_system_once(position_anchored_nodes).unwrap();
370
371 assert_eq!(
372 world.entity(badge).get::<ChildOf>().map(|c| c.parent()),
373 Some(layer),
374 "an anchored node must be reparented under the anchor layer"
375 );
376 }
377}