Skip to main content

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}