use super::overlays_enabled::active_camera_overlays_enabled;
use super::state::{NavGizmoDisc, NavGizmoDrag};
use crate::ecs::input::resources::MouseState;
use crate::ecs::text::components::TextProperties;
use crate::ecs::world::CORE;
use crate::ecs::world::{Entity, PAN_ORBIT_CAMERA, THIRD_PERSON_CAMERA, World};
use crate::render::text_data::{TextAlignment, VerticalAlignment};
use crate::render::ui_data::{UiLayer, UiRect};
use nalgebra_glm::{Mat3, Quat, Vec2, Vec3, Vec4};
const Z_INDEX_BASE: i32 = 6_000;
const ANCHOR_INSET_PX: f32 = 56.0;
const RING_RADIUS_PX: f32 = 32.0;
const DISC_RADIUS_PX: f32 = 11.0;
const HOVER_DISC_RADIUS_PX: f32 = 13.0;
const CONNECTOR_THICKNESS_PX: f32 = 2.0;
const NEGATIVE_BORDER_PX: f32 = 1.5;
const GIZMO_REGION_RADIUS_PX: f32 = RING_RADIUS_PX + HOVER_DISC_RADIUS_PX + 4.0;
const DRAG_THRESHOLD_PX: f32 = 4.0;
const LABEL_FONT_SIZE_PX: f32 = 12.0;
const FLY_FOCAL_DISTANCE: f32 = 10.0;
const X_BRIGHT: Vec4 = Vec4::new(0.95, 0.30, 0.30, 1.0);
const Y_BRIGHT: Vec4 = Vec4::new(0.40, 0.95, 0.30, 1.0);
const Z_BRIGHT: Vec4 = Vec4::new(0.30, 0.55, 0.95, 1.0);
const X_DIM: Vec4 = Vec4::new(0.55, 0.20, 0.20, 0.85);
const Y_DIM: Vec4 = Vec4::new(0.25, 0.55, 0.20, 0.85);
const Z_DIM: Vec4 = Vec4::new(0.20, 0.32, 0.55, 0.85);
const HOVER_COLOR: Vec4 = Vec4::new(1.0, 0.85, 0.20, 1.0);
const NEGATIVE_FILL: Vec4 = Vec4::new(0.0, 0.0, 0.0, 0.45);
const LABEL_COLOR: Vec4 = Vec4::new(1.0, 1.0, 1.0, 1.0);
const CONNECTOR_DIM: f32 = 0.55;
const AXES: [(Vec3, &str, Vec4, Vec4); 3] = [
(Vec3::new(1.0, 0.0, 0.0), "X", X_BRIGHT, X_DIM),
(Vec3::new(0.0, 1.0, 0.0), "Y", Y_BRIGHT, Y_DIM),
(Vec3::new(0.0, 0.0, 1.0), "Z", Z_BRIGHT, Z_DIM),
];
#[derive(Clone, Copy)]
struct DiscScreen {
center: Vec2,
depth: f32,
is_positive: bool,
axis_index: u8,
color: Vec4,
label: &'static str,
}
pub fn nav_gizmo_overlay_system(world: &mut World) {
if !world.resources.retained_ui.visible
|| !world.resources.user_interface.gizmos.nav_gizmo_enabled
|| !active_camera_overlays_enabled(world)
{
world.resources.user_interface.gizmos.nav_gizmo_drag = None;
return;
}
let Some(camera_entity) = world.resources.active_camera else {
world.resources.user_interface.gizmos.nav_gizmo_drag = None;
return;
};
let viewport = match world
.resources
.window
.camera_tile_rects
.get(&camera_entity)
.copied()
.or(world.resources.window.active_viewport_rect)
{
Some(rect) if rect.width > 1.0 && rect.height > 1.0 => rect,
_ => {
world.resources.user_interface.gizmos.nav_gizmo_drag = None;
return;
}
};
let Some(global_transform) =
world.get::<crate::ecs::transform::components::GlobalTransform>(camera_entity)
else {
return;
};
let camera_right = global_transform.right_vector();
let camera_up = global_transform.up_vector();
let camera_forward = global_transform.forward_vector();
let anchor = Vec2::new(
viewport.x + viewport.width - ANCHOR_INSET_PX,
viewport.y + ANCHOR_INSET_PX,
) + world.resources.user_interface.gizmos.nav_gizmo_offset;
let mut discs = build_discs(anchor, &camera_right, &camera_up, &camera_forward);
discs.sort_by(|a, b| {
b.depth
.partial_cmp(&a.depth)
.unwrap_or(std::cmp::Ordering::Equal)
});
let clip = Some(crate::render::ui_data::Rect::new(
viewport.x,
viewport.y,
viewport.width,
viewport.height,
));
let mouse_pos = crate::ecs::input::access::mouse_for_active(world).position;
let mouse_delta = crate::ecs::input::access::mouse_for_active(world).position_delta;
let mouse_in_viewport = viewport.contains(mouse_pos);
let just_pressed = world
.resources
.input
.mouse
.state
.contains(MouseState::LEFT_JUST_PRESSED);
let just_released = world
.resources
.input
.mouse
.state
.contains(MouseState::LEFT_JUST_RELEASED);
let held = world
.resources
.input
.mouse
.state
.contains(MouseState::LEFT_CLICKED);
let hovered_index = if mouse_in_viewport {
nearest_disc(&discs, mouse_pos)
} else {
None
};
let mut drag_state = world.resources.user_interface.gizmos.nav_gizmo_drag;
if just_pressed && mouse_in_viewport && distance(mouse_pos, anchor) <= GIZMO_REGION_RADIUS_PX {
drag_state = Some(NavGizmoDrag {
press_position: mouse_pos,
pressed_disc: hovered_index.map(|index| NavGizmoDisc {
axis_index: discs[index].axis_index,
is_positive: discs[index].is_positive,
}),
did_drag: false,
});
}
if held && let Some(drag) = drag_state.as_mut() {
let press_distance = (mouse_pos - drag.press_position).magnitude();
if !drag.did_drag && press_distance > DRAG_THRESHOLD_PX {
drag.did_drag = true;
}
if drag.did_drag && (mouse_delta.x.abs() > 0.001 || mouse_delta.y.abs() > 0.001) {
apply_orbit_drag(
world,
camera_entity,
viewport.width,
viewport.height,
mouse_delta,
);
}
}
if just_released
&& let Some(drag) = drag_state.take()
&& !drag.did_drag
&& let Some(disc) = drag.pressed_disc
{
snap_camera(world, camera_entity, disc.axis_index, disc.is_positive);
}
if !held {
drag_state = None;
}
push_connectors(world, anchor, &discs, clip);
for (sorted_index, disc) in discs.iter().enumerate() {
push_disc(
world,
disc,
hovered_index == Some(sorted_index),
sorted_index,
clip,
);
}
for disc in discs.iter() {
if disc.is_positive {
push_label(world, disc, clip);
}
}
if hovered_index.is_some() || drag_state.is_some() {
world.resources.user_interface.hud_wants_pointer = true;
}
world.resources.user_interface.gizmos.nav_gizmo_drag = drag_state;
}
fn distance(a: Vec2, b: Vec2) -> f32 {
let delta = a - b;
(delta.x * delta.x + delta.y * delta.y).sqrt()
}
fn build_discs(anchor: Vec2, right: &Vec3, up: &Vec3, forward: &Vec3) -> [DiscScreen; 6] {
let mut discs = [DiscScreen {
center: Vec2::zeros(),
depth: 0.0,
is_positive: true,
axis_index: 0,
color: Vec4::zeros(),
label: "",
}; 6];
let mut slot = 0;
for (axis_index, (axis_world, label, bright, dim)) in AXES.iter().copied().enumerate() {
for (sign, is_positive) in [(1.0_f32, true), (-1.0_f32, false)] {
let direction = axis_world * sign;
let right_proj = nalgebra_glm::dot(&direction, right);
let up_proj = nalgebra_glm::dot(&direction, up);
let forward_proj = nalgebra_glm::dot(&direction, forward);
discs[slot] = DiscScreen {
center: anchor + Vec2::new(right_proj, -up_proj) * RING_RADIUS_PX,
depth: forward_proj,
is_positive,
axis_index: axis_index as u8,
color: if is_positive { bright } else { dim },
label,
};
slot += 1;
}
}
discs
}
fn nearest_disc(discs: &[DiscScreen; 6], mouse_pos: Vec2) -> Option<usize> {
let radius_squared = HOVER_DISC_RADIUS_PX * HOVER_DISC_RADIUS_PX;
let mut best: Option<(usize, f32)> = None;
for (index, disc) in discs.iter().enumerate() {
let to_mouse = mouse_pos - disc.center;
let distance_squared = to_mouse.x * to_mouse.x + to_mouse.y * to_mouse.y;
if distance_squared > radius_squared {
continue;
}
match best {
Some((_, current)) if current <= distance_squared => {}
_ => best = Some((index, distance_squared)),
}
}
best.map(|(index, _)| index)
}
fn push_connectors(
world: &mut World,
anchor: Vec2,
discs: &[DiscScreen; 6],
clip: Option<crate::render::ui_data::Rect>,
) {
for (sorted_index, disc) in discs.iter().enumerate() {
if !disc.is_positive {
continue;
}
let mut color = disc.color;
color.w *= CONNECTOR_DIM;
push_segment(
world,
anchor,
disc.center,
color,
CONNECTOR_THICKNESS_PX,
sorted_index,
clip,
);
}
}
fn push_segment(
world: &mut World,
from: Vec2,
to: Vec2,
color: Vec4,
thickness: f32,
sorted_index: usize,
clip: Option<crate::render::ui_data::Rect>,
) {
let delta = to - from;
let length = (delta.x * delta.x + delta.y * delta.y).sqrt();
if length < 0.5 {
return;
}
let midpoint = (from + to) * 0.5;
let angle = delta.y.atan2(delta.x);
let position = midpoint - Vec2::new(length * 0.5, thickness * 0.5);
world.resources.retained_ui.frame.rects.push(UiRect {
position,
size: Vec2::new(length, thickness),
color,
corner_radius: thickness * 0.5,
border_width: 0.0,
border_color: Vec4::zeros(),
rotation: angle,
clip_rect: clip,
layer: UiLayer::Background,
z_index: Z_INDEX_BASE + (sorted_index as i32) * 3,
shadow: None,
effect_kind: 0,
effect_params: [0.0; 4],
quad_corners: None,
});
}
fn push_disc(
world: &mut World,
disc: &DiscScreen,
hovered: bool,
sorted_index: usize,
clip: Option<crate::render::ui_data::Rect>,
) {
let radius = if hovered {
HOVER_DISC_RADIUS_PX
} else {
DISC_RADIUS_PX
};
let size = Vec2::new(radius * 2.0, radius * 2.0);
let position = disc.center - Vec2::new(radius, radius);
let resolved_color = if hovered { HOVER_COLOR } else { disc.color };
let (fill_color, border_width, border_color) = if disc.is_positive || hovered {
(resolved_color, 0.0, Vec4::zeros())
} else {
(NEGATIVE_FILL, NEGATIVE_BORDER_PX, resolved_color)
};
world.resources.retained_ui.frame.rects.push(UiRect {
position,
size,
color: fill_color,
corner_radius: radius,
border_width,
border_color,
rotation: 0.0,
clip_rect: clip,
layer: UiLayer::Background,
z_index: Z_INDEX_BASE + (sorted_index as i32) * 3 + 1,
shadow: None,
effect_kind: 0,
effect_params: [0.0; 4],
quad_corners: None,
});
}
fn push_label(world: &mut World, disc: &DiscScreen, clip: Option<crate::render::ui_data::Rect>) {
let dpi_scale = world.resources.window.cached_scale_factor.max(0.0001);
let properties = TextProperties {
font_size: LABEL_FONT_SIZE_PX * dpi_scale,
color: LABEL_COLOR,
alignment: TextAlignment::Center,
vertical_alignment: VerticalAlignment::Middle,
line_height: 1.0,
letter_spacing: 0.0,
outline_width: 0.0,
outline_color: Vec4::new(0.0, 0.0, 0.0, 0.0),
smoothing: 0.003,
monospace_width: None,
anchor_character: None,
font_kind: crate::render::text_data::FontKind::Default,
};
let position = Vec2::new(disc.center.x.round(), disc.center.y.round());
world.resources.retained_ui.draw_overlay_text(
disc.label,
position,
properties,
clip,
UiLayer::Background,
Z_INDEX_BASE + 200,
);
}
fn apply_orbit_drag(
world: &mut World,
camera_entity: Entity,
viewport_width: f32,
viewport_height: f32,
mouse_delta: Vec2,
) {
let delta_x = (mouse_delta.x / viewport_width.max(1.0)) * std::f32::consts::PI * 2.0;
let delta_y = (mouse_delta.y / viewport_height.max(1.0)) * std::f32::consts::PI;
if world.ecs.worlds[CORE].entity_has_components(camera_entity, PAN_ORBIT_CAMERA)
&& let Some(pan_orbit) =
world.get_mut::<crate::ecs::camera::components::PanOrbitCamera>(camera_entity)
{
let signed_delta_x = if pan_orbit.is_upside_down {
-delta_x
} else {
delta_x
};
pan_orbit.target_yaw -= signed_delta_x * pan_orbit.sensitivity.orbit;
pan_orbit.target_pitch += delta_y * pan_orbit.sensitivity.orbit;
if pan_orbit.limits.allow_upside_down {
pan_orbit.target_pitch %= std::f32::consts::PI * 2.0;
} else {
pan_orbit.target_pitch = pan_orbit
.target_pitch
.clamp(pan_orbit.limits.pitch_lower, pan_orbit.limits.pitch_upper);
}
return;
}
if world.ecs.worlds[CORE].entity_has_components(camera_entity, THIRD_PERSON_CAMERA)
&& let Some(third_person) =
world.get_mut::<crate::ecs::camera::components::ThirdPersonCamera>(camera_entity)
{
third_person.target_yaw -= delta_x * third_person.orbit_sensitivity;
third_person.target_pitch =
(third_person.target_pitch + delta_y * third_person.orbit_sensitivity).clamp(
third_person.pitch_lower_limit,
third_person.pitch_upper_limit,
);
return;
}
rotate_fly_camera(world, camera_entity, delta_x, delta_y);
}
fn rotate_fly_camera(world: &mut World, camera_entity: Entity, delta_x: f32, delta_y: f32) {
let Some(local_transform) =
world.get_mut::<crate::ecs::transform::components::LocalTransform>(camera_entity)
else {
return;
};
let yaw_quat = nalgebra_glm::quat_angle_axis(-delta_x, &Vec3::y());
local_transform.rotation = yaw_quat * local_transform.rotation;
let forward = local_transform.forward_vector();
let current_pitch = forward.y.asin();
let pitch_limit = 89.0_f32.to_radians();
let new_pitch = current_pitch + delta_y;
if new_pitch.abs() <= pitch_limit {
let pitch_quat = nalgebra_glm::quat_angle_axis(delta_y, &Vec3::x());
local_transform.rotation *= pitch_quat;
}
}
fn snap_camera(world: &mut World, camera_entity: Entity, axis_index: u8, is_positive: bool) {
if try_snap_pan_orbit(world, camera_entity, axis_index, is_positive) {
return;
}
if try_snap_third_person(world, camera_entity, axis_index, is_positive) {
return;
}
snap_fly_camera(world, camera_entity, axis_index, is_positive);
}
fn try_snap_pan_orbit(
world: &mut World,
camera_entity: Entity,
axis_index: u8,
is_positive: bool,
) -> bool {
if !world.ecs.worlds[CORE].entity_has_components(camera_entity, PAN_ORBIT_CAMERA) {
return false;
}
let (desired_yaw, desired_pitch) = canonical_view_angles(axis_index, is_positive);
let Some(pan_orbit) =
world.get_mut::<crate::ecs::camera::components::PanOrbitCamera>(camera_entity)
else {
return false;
};
if let Some(yaw_value) = desired_yaw {
pan_orbit.target_yaw = shortest_angle_target(pan_orbit.target_yaw, yaw_value);
}
if let Some(pitch_value) = desired_pitch {
pan_orbit.target_pitch =
pitch_value.clamp(pan_orbit.limits.pitch_lower, pan_orbit.limits.pitch_upper);
}
true
}
fn try_snap_third_person(
world: &mut World,
camera_entity: Entity,
axis_index: u8,
is_positive: bool,
) -> bool {
if !world.ecs.worlds[CORE].entity_has_components(camera_entity, THIRD_PERSON_CAMERA) {
return false;
}
let (desired_yaw, desired_pitch) = canonical_view_angles(axis_index, is_positive);
let Some(third_person) =
world.get_mut::<crate::ecs::camera::components::ThirdPersonCamera>(camera_entity)
else {
return false;
};
if let Some(yaw_value) = desired_yaw {
third_person.target_yaw = shortest_angle_target(third_person.target_yaw, yaw_value);
}
if let Some(pitch_value) = desired_pitch {
third_person.target_pitch = pitch_value.clamp(
third_person.pitch_lower_limit,
third_person.pitch_upper_limit,
);
}
true
}
fn snap_fly_camera(world: &mut World, camera_entity: Entity, axis_index: u8, is_positive: bool) {
let snap_axis = match (axis_index, is_positive) {
(0, true) => Vec3::new(1.0, 0.0, 0.0),
(0, false) => Vec3::new(-1.0, 0.0, 0.0),
(1, true) => Vec3::new(0.0, 1.0, 0.0),
(1, false) => Vec3::new(0.0, -1.0, 0.0),
(2, true) => Vec3::new(0.0, 0.0, 1.0),
(2, false) => Vec3::new(0.0, 0.0, -1.0),
_ => return,
};
let world_up = if snap_axis.y.abs() > 0.99 {
Vec3::new(0.0, 0.0, 1.0)
} else {
Vec3::new(0.0, 1.0, 0.0)
};
let Some(local_transform) =
world.get_mut::<crate::ecs::transform::components::LocalTransform>(camera_entity)
else {
return;
};
let current_position = local_transform.translation;
let current_forward = local_transform.forward_vector();
let focal = current_position + current_forward * FLY_FOCAL_DISTANCE;
let new_position = focal + snap_axis * FLY_FOCAL_DISTANCE;
let look_direction = nalgebra_glm::normalize(&(focal - new_position));
let new_rotation = look_rotation(look_direction, world_up);
local_transform.translation = new_position;
local_transform.rotation = new_rotation;
}
fn look_rotation(forward: Vec3, up_hint: Vec3) -> Quat {
let forward_normalized = nalgebra_glm::normalize(&forward);
let right = nalgebra_glm::normalize(&nalgebra_glm::cross(&forward_normalized, &up_hint));
let up = nalgebra_glm::cross(&right, &forward_normalized);
let basis = Mat3::from_columns(&[right, up, -forward_normalized]);
nalgebra_glm::mat3_to_quat(&basis)
}
fn canonical_view_angles(axis_index: u8, is_positive: bool) -> (Option<f32>, Option<f32>) {
let pi = std::f32::consts::PI;
let half_pi = std::f32::consts::FRAC_PI_2;
match (axis_index, is_positive) {
(0, true) => (Some(half_pi), Some(0.0)),
(0, false) => (Some(-half_pi), Some(0.0)),
(1, true) => (None, Some(half_pi)),
(1, false) => (None, Some(-half_pi)),
(2, true) => (Some(0.0), Some(0.0)),
(2, false) => (Some(pi), Some(0.0)),
_ => (None, None),
}
}
fn shortest_angle_target(current: f32, desired: f32) -> f32 {
let tau = std::f32::consts::TAU;
let raw = (desired - current).rem_euclid(tau);
let signed = if raw > std::f32::consts::PI {
raw - tau
} else {
raw
};
current + signed
}