bevy_react/anchor.rs
1//! World-anchored UI overlays (`Anchored.node`).
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 `Anchored.node`'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 `Anchored.node`. 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 // The layer is spawned at the window origin (absolute, `left`/`top` 0, child of the
160 // full-window root), so anchored nodes — its children — position relative to (0,0).
161 // Using a constant rather than reading the layer's computed transform keeps this
162 // correct on the frame a node is first reparented (the reparent command below only
163 // applies at the next sync point) and avoids any layout-readiness dependency.
164 let parent_top_left = Vec2::ZERO;
165
166 for (entity, anchor, child_of, mut node, mut visibility, mut transform) in &mut anchored {
167 // Move the overlay into the shared anchor layer (once; self-heals on reorder) so
168 // it can't affect its declared parent's flex layout or scroll range. Done before
169 // the layout-readiness guards so it happens even while the node waits to be laid
170 // out below.
171 if child_of.map(|c| c.parent()) != Some(layer_entity) {
172 commands.entity(entity).insert(ChildOf(layer_entity));
173 }
174
175 // Always absolute, so a hidden overlay never takes flex-flow space in its
176 // parent (e.g. while it waits to be positioned below).
177 node.position_type = PositionType::Absolute;
178
179 // Center the overlay on the anchor using its own laid-out size. On the frame
180 // it spawns, `bevy_ui` layout hasn't produced a size yet (it runs later, in
181 // `PostUpdate`) and the target's transform may not have propagated — so stay
182 // hidden one frame rather than flash uncentered at a stale position. By the
183 // next frame the size is real and the transforms have settled.
184 let Ok((computed, _)) = ui_nodes.get(entity) else {
185 set_visibility(&mut visibility, Visibility::Hidden);
186 continue;
187 };
188 if computed.size().x <= 0.0 {
189 set_visibility(&mut visibility, Visibility::Hidden);
190 continue;
191 }
192
193 // The target may have despawned (or not exist yet): hide until it returns.
194 let Ok(target_tf) = targets.get(anchor.target) else {
195 set_visibility(&mut visibility, Visibility::Hidden);
196 continue;
197 };
198
199 let world = target_tf.translation() + anchor.offset;
200 let Ok(viewport) = cam.world_to_viewport(cam_tf, world) else {
201 // Behind the camera / outside the viewport: hide rather than clamp.
202 set_visibility(&mut visibility, Visibility::Hidden);
203 continue;
204 };
205
206 // Distance-based scaling (applied via `UiTransform`, which scales about the
207 // node center, so the overlay stays centered on its anchor). `None` → 1.
208 let scale = match &anchor.scale {
209 Some(c) => distance_scale(c, world.distance(cam_tf.translation())),
210 None => 1.0,
211 };
212 if transform.scale != Vec2::splat(scale) {
213 transform.scale = Vec2::splat(scale);
214 }
215
216 // `world_to_viewport` is in logical pixels, but `bevy_ui` positions an
217 // absolute node relative to its parent's box — so subtract the anchor layer's
218 // top-left (computed once above). Also center this node on the anchor using its
219 // own size.
220 let half = computed.size() * computed.inverse_scale_factor() / 2.0;
221 let local = viewport - parent_top_left - half;
222
223 node.left = Val::Px(local.x);
224 node.top = Val::Px(local.y);
225 set_visibility(&mut visibility, Visibility::Inherited);
226 }
227}
228
229/// Assign `visibility` only when it actually changes, so we don't trip change
230/// detection (and re-propagate visibility) every frame for a stationary overlay.
231fn set_visibility(visibility: &mut Mut<Visibility>, next: Visibility) {
232 if **visibility != next {
233 **visibility = next;
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::{AnchorScaling, distance_scale};
240 use crate::protocol::Props;
241
242 fn scaling(min: f32, max: f32, factor: f32, base_distance: f32) -> AnchorScaling {
243 AnchorScaling {
244 min,
245 max,
246 factor,
247 base_distance,
248 }
249 }
250
251 /// Reversed bounds would panic `f32::clamp` every frame; `sanitized` swaps
252 /// them instead.
253 #[test]
254 fn sanitize_swaps_reversed_bounds() {
255 let s = scaling(2.0, 0.4, 1.0, 24.0).sanitized().expect("kept");
256 assert_eq!((s.min, s.max), (0.4, 2.0));
257 // An already-valid config passes through unchanged.
258 let s = scaling(0.4, 2.0, 1.0, 24.0).sanitized().expect("kept");
259 assert_eq!((s.min, s.max), (0.4, 2.0));
260 }
261
262 /// Any non-finite field disables scaling entirely (NaN bounds would panic
263 /// `f32::clamp`; a NaN factor/base_distance would produce a NaN scale).
264 #[test]
265 fn sanitize_rejects_non_finite_fields() {
266 for bad in [
267 scaling(f32::NAN, 2.0, 1.0, 24.0),
268 scaling(0.4, f32::NAN, 1.0, 24.0),
269 scaling(0.4, 2.0, f32::NAN, 24.0),
270 scaling(0.4, 2.0, 1.0, f32::NAN),
271 scaling(0.4, f32::INFINITY, 1.0, 24.0),
272 ] {
273 assert!(bad.sanitized().is_none(), "kept {bad:?}");
274 }
275 }
276
277 /// `dist == 0` (camera exactly on the anchor) must stay finite: the `inf`
278 /// ratio pins to `max`, including the `factor == 0` case whose `0 * inf`
279 /// product is NaN.
280 #[test]
281 fn distance_scale_is_finite_at_zero_distance() {
282 let c = scaling(0.4, 2.0, 1.0, 24.0);
283 assert_eq!(distance_scale(&c, 0.0), 2.0);
284 let flat = scaling(0.4, 2.0, 0.0, 24.0);
285 assert_eq!(distance_scale(&flat, 0.0), 2.0);
286 // Normal case: at exactly base_distance the scale is 1.
287 assert_eq!(distance_scale(&c, 24.0), 1.0);
288 }
289
290 #[test]
291 fn props_deserialize_anchor() {
292 // entity bits 2^32 + 1 = index 1, generation 1 (a realistic value).
293 let props: Props = serde_json::from_value(serde_json::json!({
294 "anchor": {
295 "entity": 4294967297u64,
296 "offset": [0.0, 1.0, 0.0],
297 "scale": { "min": 0.4, "max": 2.0, "factor": 1.0, "baseDistance": 24.0 }
298 }
299 }))
300 .unwrap();
301 let anchor = props.anchor.expect("anchor present");
302 assert_eq!(anchor.entity as u64, 4_294_967_297);
303 assert_eq!(anchor.offset, Some([0.0, 1.0, 0.0]));
304 let scale = anchor.scale.expect("scale present");
305 assert_eq!(
306 (scale.min, scale.max, scale.factor, scale.base_distance),
307 (0.4, 2.0, 1.0, 24.0)
308 );
309 }
310
311 #[test]
312 fn anchor_offset_defaults_to_none() {
313 let props: Props = serde_json::from_value(serde_json::json!({
314 "anchor": { "entity": 1u64 }
315 }))
316 .unwrap();
317 assert_eq!(props.anchor.unwrap().offset, None);
318 }
319
320 #[test]
321 fn anchored_node_is_reparented_under_the_layer() {
322 use super::{AnchorLayer, Anchored, position_anchored_nodes};
323 use bevy::ecs::system::RunSystemOnce;
324 use bevy::prelude::*;
325 use bevy::ui::{ComputedNode, IsDefaultUiCamera, UiGlobalTransform};
326
327 let mut world = World::new();
328
329 // A default UI camera so the system gets past its camera guard.
330 world.spawn((
331 Camera::default(),
332 GlobalTransform::default(),
333 IsDefaultUiCamera,
334 ));
335
336 // The shared overlay layer (carries the components the layer query reads).
337 let layer = world
338 .spawn((
339 AnchorLayer,
340 ComputedNode::default(),
341 UiGlobalTransform::default(),
342 ))
343 .id();
344
345 // Some unrelated container the overlay was "declared" under in the React tree.
346 let other_parent = world.spawn(Node::default()).id();
347
348 // An anchored node parented under `other_parent` (not the layer). It has no
349 // `ComputedNode`, so it stays hidden — but the reparent runs first regardless.
350 let target = world.spawn(GlobalTransform::default()).id();
351 let badge = world
352 .spawn((
353 Node::default(),
354 Anchored {
355 target,
356 offset: Vec3::ZERO,
357 scale: None,
358 },
359 ChildOf(other_parent),
360 ))
361 .id();
362
363 world.run_system_once(position_anchored_nodes).unwrap();
364
365 assert_eq!(
366 world.entity(badge).get::<ChildOf>().map(|c| c.parent()),
367 Some(layer),
368 "an anchored node must be reparented under the anchor layer"
369 );
370 }
371}