use bevy::prelude::*;
use bevy::ui::{IsDefaultUiCamera, UiGlobalTransform, UiTransform, Val2};
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Anchor {
pub entity: f64,
#[serde(default)]
pub offset: Option<[f32; 3]>,
#[serde(default)]
pub scale: Option<AnchorScaling>,
}
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AnchorScaling {
pub min: f32,
pub max: f32,
pub factor: f32,
pub base_distance: f32,
}
impl AnchorScaling {
pub(crate) fn sanitized(self) -> Option<Self> {
if ![self.min, self.max, self.factor, self.base_distance]
.iter()
.all(|v| v.is_finite())
{
warn!("non-finite anchor scale config {self:?}; disabling distance scaling");
return None;
}
if self.min > self.max {
warn!(
"anchor scale min {} > max {}; swapping the bounds",
self.min, self.max
);
return Some(Self {
min: self.max,
max: self.min,
..self
});
}
Some(self)
}
}
fn distance_scale(c: &AnchorScaling, dist: f32) -> f32 {
let raw = 1.0 + c.factor * (c.base_distance / dist - 1.0);
if raw.is_nan() {
c.max
} else {
raw.clamp(c.min, c.max)
}
}
#[derive(Component, Debug, Clone, Copy)]
pub struct AnchorLayer;
#[derive(Component, Debug, Clone)]
#[require(Visibility, UiTransform)]
pub struct Anchored {
pub target: Entity,
pub offset: Vec3,
pub scale: Option<AnchorScaling>,
}
#[allow(clippy::type_complexity)]
pub fn position_anchored_nodes(
mut commands: Commands,
default_cam: Query<(&Camera, &GlobalTransform), With<IsDefaultUiCamera>>,
other_cam: Query<(&Camera, &GlobalTransform), Without<IsDefaultUiCamera>>,
layer: Query<Entity, With<AnchorLayer>>,
targets: Query<&GlobalTransform>,
ui_nodes: Query<(&ComputedNode, &UiGlobalTransform)>,
mut anchored: Query<(
Entity,
&Anchored,
Option<&ChildOf>,
&mut Node,
&mut Visibility,
&mut UiTransform,
)>,
) {
let Some((cam, cam_tf)) = default_cam
.iter()
.next()
.or_else(|| other_cam.iter().next())
else {
return;
};
let Ok(layer_entity) = layer.single() else {
return;
};
let parent_top_left = ui_nodes
.get(layer_entity)
.map(|(c, t)| t.translation * c.inverse_scale_factor())
.unwrap_or(Vec2::ZERO);
for (entity, anchor, child_of, mut node, mut visibility, mut transform) in &mut anchored {
if child_of.map(|c| c.parent()) != Some(layer_entity) {
commands.entity(entity).insert(ChildOf(layer_entity));
}
if node.position_type != PositionType::Absolute
|| node.left != Val::Px(0.0)
|| node.top != Val::Px(0.0)
{
node.position_type = PositionType::Absolute;
node.left = Val::Px(0.0);
node.top = Val::Px(0.0);
}
let Ok((computed, _)) = ui_nodes.get(entity) else {
set_visibility(&mut visibility, Visibility::Hidden);
continue;
};
if computed.size().x <= 0.0 {
set_visibility(&mut visibility, Visibility::Hidden);
continue;
}
let Ok(target_tf) = targets.get(anchor.target) else {
set_visibility(&mut visibility, Visibility::Hidden);
continue;
};
let world = target_tf.translation() + anchor.offset;
let Ok(viewport) = cam.world_to_viewport(cam_tf, world) else {
set_visibility(&mut visibility, Visibility::Hidden);
continue;
};
let scale = match &anchor.scale {
Some(c) => distance_scale(c, world.distance(cam_tf.translation())),
None => 1.0,
};
if transform.scale != Vec2::splat(scale) {
transform.scale = Vec2::splat(scale);
}
let half = computed.size() * computed.inverse_scale_factor() / 2.0;
let local = viewport - parent_top_left - half;
let translation = Val2::px(local.x, local.y);
if transform.translation != translation {
transform.translation = translation;
}
set_visibility(&mut visibility, Visibility::Inherited);
}
}
fn set_visibility(visibility: &mut Mut<Visibility>, next: Visibility) {
if **visibility != next {
**visibility = next;
}
}
#[cfg(test)]
mod tests {
use super::{AnchorScaling, distance_scale};
use crate::protocol::props::Props;
fn scaling(min: f32, max: f32, factor: f32, base_distance: f32) -> AnchorScaling {
AnchorScaling {
min,
max,
factor,
base_distance,
}
}
#[test]
fn sanitize_swaps_reversed_bounds() {
let s = scaling(2.0, 0.4, 1.0, 24.0).sanitized().expect("kept");
assert_eq!((s.min, s.max), (0.4, 2.0));
let s = scaling(0.4, 2.0, 1.0, 24.0).sanitized().expect("kept");
assert_eq!((s.min, s.max), (0.4, 2.0));
}
#[test]
fn sanitize_rejects_non_finite_fields() {
for bad in [
scaling(f32::NAN, 2.0, 1.0, 24.0),
scaling(0.4, f32::NAN, 1.0, 24.0),
scaling(0.4, 2.0, f32::NAN, 24.0),
scaling(0.4, 2.0, 1.0, f32::NAN),
scaling(0.4, f32::INFINITY, 1.0, 24.0),
] {
assert!(bad.sanitized().is_none(), "kept {bad:?}");
}
}
#[test]
fn distance_scale_is_finite_at_zero_distance() {
let c = scaling(0.4, 2.0, 1.0, 24.0);
assert_eq!(distance_scale(&c, 0.0), 2.0);
let flat = scaling(0.4, 2.0, 0.0, 24.0);
assert_eq!(distance_scale(&flat, 0.0), 2.0);
assert_eq!(distance_scale(&c, 24.0), 1.0);
}
#[test]
fn props_deserialize_anchor() {
let props: Props = serde_json::from_value(serde_json::json!({
"anchor": {
"entity": 4294967297u64,
"offset": [0.0, 1.0, 0.0],
"scale": { "min": 0.4, "max": 2.0, "factor": 1.0, "baseDistance": 24.0 }
}
}))
.unwrap();
let anchor = props.anchor.expect("anchor present");
assert_eq!(anchor.entity as u64, 4_294_967_297);
assert_eq!(anchor.offset, Some([0.0, 1.0, 0.0]));
let scale = anchor.scale.expect("scale present");
assert_eq!(
(scale.min, scale.max, scale.factor, scale.base_distance),
(0.4, 2.0, 1.0, 24.0)
);
}
#[test]
fn anchor_offset_defaults_to_none() {
let props: Props = serde_json::from_value(serde_json::json!({
"anchor": { "entity": 1u64 }
}))
.unwrap();
assert_eq!(props.anchor.unwrap().offset, None);
}
#[test]
fn anchored_node_is_reparented_under_the_layer() {
use super::{AnchorLayer, Anchored, position_anchored_nodes};
use bevy::ecs::system::RunSystemOnce;
use bevy::prelude::*;
use bevy::ui::{ComputedNode, IsDefaultUiCamera, UiGlobalTransform};
let mut world = World::new();
world.spawn((
Camera::default(),
GlobalTransform::default(),
IsDefaultUiCamera,
));
let layer = world
.spawn((
AnchorLayer,
ComputedNode::default(),
UiGlobalTransform::default(),
))
.id();
let other_parent = world.spawn(Node::default()).id();
let target = world.spawn(GlobalTransform::default()).id();
let badge = world
.spawn((
Node::default(),
Anchored {
target,
offset: Vec3::ZERO,
scale: None,
},
ChildOf(other_parent),
))
.id();
world.run_system_once(position_anchored_nodes).unwrap();
assert_eq!(
world.entity(badge).get::<ChildOf>().map(|c| c.parent()),
Some(layer),
"an anchored node must be reparented under the anchor layer"
);
}
#[test]
fn anchored_move_never_ticks_node() {
use super::{AnchorLayer, Anchored, position_anchored_nodes};
use bevy::camera::{ComputedCameraValues, RenderTargetInfo};
use bevy::ecs::schedule::Schedule;
use bevy::prelude::*;
use bevy::ui::{ComputedNode, IsDefaultUiCamera, UiGlobalTransform, UiTransform, Val2};
#[derive(Resource, Default)]
struct Probe {
node: usize,
transform: usize,
}
let camera = Camera {
computed: ComputedCameraValues {
clip_from_view: Mat4::perspective_infinite_reverse_rh(
std::f32::consts::FRAC_PI_4,
1.0,
0.1,
),
target_info: Some(RenderTargetInfo {
physical_size: UVec2::new(1000, 1000),
scale_factor: 1.0,
}),
..Default::default()
},
..Default::default()
};
let cam_tf = GlobalTransform::default(); let half = Vec2::new(10.0, 5.0); let expected = |world_pos: Vec3| {
let viewport = camera
.world_to_viewport(&cam_tf, world_pos)
.expect("test points are in front of the camera");
Val2::px(viewport.x - half.x, viewport.y - half.y)
};
let mut world = World::new();
world.init_resource::<Probe>();
world.spawn((camera.clone(), cam_tf, IsDefaultUiCamera));
let layer = world
.spawn((
AnchorLayer,
ComputedNode::default(),
UiGlobalTransform::default(),
))
.id();
let pos_a = Vec3::new(0.0, 0.0, -10.0);
let target = world.spawn(GlobalTransform::from_translation(pos_a)).id();
let badge = world
.spawn((
Node::default(),
ComputedNode {
size: Vec2::new(20.0, 10.0),
inverse_scale_factor: 1.0,
..Default::default()
},
UiGlobalTransform::default(),
Anchored {
target,
offset: Vec3::ZERO,
scale: None,
},
ChildOf(layer),
))
.id();
let mut apply = Schedule::default();
apply.add_systems(position_anchored_nodes);
let mut detect = Schedule::default();
detect.add_systems(
|nodes: Query<(), (Changed<Node>, With<Anchored>)>,
transforms: Query<(), (Changed<UiTransform>, With<Anchored>)>,
mut probe: ResMut<Probe>| {
probe.node += nodes.iter().count();
probe.transform += transforms.iter().count();
},
);
apply.run(&mut world);
detect.run(&mut world);
assert_eq!(
world
.entity(badge)
.get::<UiTransform>()
.unwrap()
.translation,
expected(pos_a),
"the projected position lands in the transform translation"
);
assert_eq!(
world.entity(badge).get::<Visibility>(),
Some(&Visibility::Inherited),
"an on-screen anchor is visible"
);
*world.resource_mut::<Probe>() = Probe::default();
let pos_b = Vec3::new(2.0, 1.0, -10.0);
world
.entity_mut(target)
.insert(GlobalTransform::from_translation(pos_b));
apply.run(&mut world);
detect.run(&mut world);
let probe = world.resource::<Probe>();
assert_eq!(
(probe.node, probe.transform),
(0, 1),
"a moving anchor must ride the transform, never Node (a relayout)"
);
assert_eq!(
world
.entity(badge)
.get::<UiTransform>()
.unwrap()
.translation,
expected(pos_b),
"the overlay follows the moved target"
);
*world.resource_mut::<Probe>() = Probe::default();
apply.run(&mut world);
detect.run(&mut world);
let probe = world.resource::<Probe>();
assert_eq!(
(probe.node, probe.transform),
(0, 0),
"a static anchor must tick neither Node nor UiTransform"
);
}
}