use bevy::prelude::*;
use bevy::ui::{IsDefaultUiCamera, UiGlobalTransform, UiTransform};
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 = 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));
}
node.position_type = PositionType::Absolute;
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;
node.left = Val::Px(local.x);
node.top = Val::Px(local.y);
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;
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"
);
}
}