//! Unified viewport overlay chrome (toolbars, badges, HUD). use bevy::prelude::*; use bevy_egui::{egui, EguiTextureHandle, EguiUserTextures}; use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities; use egui_phosphor_icons::icons; use transform_gizmo_bevy::prelude::GizmoOptions; use crate::assets::operators::{ apply_material_operator, apply_texture_operator, place_asset_operator, place_subasset_operator, }; use crate::assets::{AssetSelection, AssetSubAssetKind, EditorAssets}; use crate::gizmos::{EditorGizmoMode, EditorGizmoSpace}; use crate::render_target::ViewportRenderTarget; use crate::selection::ViewportClick; use crate::state::PlayPossession; use crate::viewport::actor_icons::ActorIconSettings; use crate::viewport::brush_edit::BrushEditMode; use crate::viewport::{ snap_translation, viewport_ground_position, EditorViewportMode, ViewportDisplayMode, ViewportSettings, }; use super::helpers::{ focus_editor_camera_on_selection, viewport_recall_bookmark, viewport_save_bookmark, }; use super::theme::viewport_toolbar_frame; use super::widgets::{icon_button_small, panel_toolbar_row, tool_button}; #[derive(Resource, Default)] pub struct ViewportUiState { pub options_open: bool, pub shortcuts_open: bool, } const VIEWPORT_TOOLTIP: &str = "RMB + WASD/QE: fly | MMB: pan | Scroll: dolly\nW/E/R: gizmo | X: world/local | F: focus | G: game view | Ctrl+G: grid\nTab: cycle overlapping picks | Play/Pause/Stop: main toolbar (F5 / F6)"; pub fn viewport_tab_ui( world: &mut World, ui: &mut egui::Ui, viewport_rect: &mut egui::Rect, pointer_in_viewport: &mut bool, viewport_pointer_pos: &mut Option, selected_entities: &SelectedEntities, playing: bool, _cursor_grabbed: bool, ) { let rect = ui.available_rect_before_wrap(); *viewport_rect = rect; if rect.width() <= 1.0 || rect.height() <= 1.0 { *pointer_in_viewport = false; *viewport_pointer_pos = None; return; } let clean_game_view = world.resource::().clean_game_view; let response = ui.allocate_rect(rect, egui::Sense::HOVER); *viewport_pointer_pos = response .interact_pointer_pos() .or_else(|| response.hover_pos()) .filter(|pos| rect.contains(*pos)); *pointer_in_viewport = viewport_pointer_pos.is_some(); let primary_clicked = ui.input(|input| { input.pointer.button_clicked(egui::PointerButton::Primary) && !input.modifiers.alt }); if primary_clicked && !clean_game_view { if let Some(pos) = response .interact_pointer_pos() .or_else(|| response.hover_pos()) .filter(|pos| rect.contains(*pos)) { world.resource_mut::().0 = Some(pos); } } if let Some(target) = world .get_resource::() .and_then(|target| target.0.clone()) { let mut textures = world.resource_mut::(); let texture_id = textures .image_id(target.image.id()) .unwrap_or_else(|| textures.add_image(EguiTextureHandle::Strong(target.image.clone()))); let painter = ui.painter(); painter.rect_filled(rect, 0.0, egui::Color32::BLACK); painter.image( texture_id, rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE, ); } else { ui.allocate_rect(rect, egui::Sense::hover()); ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { ui.centered_and_justified(|ui| { ui.label(egui::RichText::new("Viewport").weak()); }); }); } if clean_game_view { if playing { game_view_overlay(world, ui.ctx(), rect, playing); } return; } scene_view_overlay_toolbar(world, ui.ctx(), rect); scene_view_mode_badge(world, ui.ctx(), rect); scene_view_brush_mode_badge(world, ui.ctx(), rect); scene_view_gi_badge(world, ui.ctx(), rect); scene_view_volume_hud(world, ui.ctx(), rect); viewport_asset_drop_ui(world, ui, rect, selected_entities); } fn scene_view_mode_badge(world: &World, ctx: &egui::Context, scene_rect: egui::Rect) { let Some(mode) = world.get_resource::() else { return; }; let mode = *mode; let (label, color) = match mode { EditorViewportMode::Lit => ("Lit", egui::Color32::from_rgb(120, 200, 120)), EditorViewportMode::Unlit => ("Unlit", egui::Color32::from_rgb(200, 200, 120)), EditorViewportMode::Collider => ("Collider", egui::Color32::from_rgb(220, 140, 80)), }; egui::Area::new(egui::Id::new("scene_view_mode_badge")) .fixed_pos(scene_rect.right_bottom() + egui::vec2(-72.0, -22.0)) .interactable(false) .show(ctx, |ui| { egui::Frame::NONE .fill(egui::Color32::from_black_alpha(160)) .inner_margin(egui::Margin::symmetric(8, 4)) .show(ui, |ui| { ui.colored_label(color, label); }); }); if mode == EditorViewportMode::Collider { egui::Area::new(egui::Id::new("scene_view_collider_warning")) .fixed_pos(scene_rect.left_bottom() + egui::vec2(8.0, -28.0)) .interactable(false) .show(ctx, |ui| { ui.colored_label( egui::Color32::from_rgb(255, 180, 100), "Meshes hidden - switch to Lit (sun icon) for shading", ); }); } } fn scene_view_brush_mode_badge(world: &World, ctx: &egui::Context, scene_rect: egui::Rect) { let Some(mode) = world.get_resource::().copied() else { return; }; if !mode.is_element_mode() { return; } egui::Area::new(egui::Id::new("scene_view_brush_mode_badge")) .fixed_pos(scene_rect.right_bottom() + egui::vec2(-150.0, -22.0)) .interactable(false) .show(ctx, |ui| { egui::Frame::new() .fill(egui::Color32::from_rgba_unmultiplied(35, 44, 50, 220)) .corner_radius(egui::CornerRadius::same(3)) .inner_margin(egui::Margin::symmetric(6, 2)) .show(ui, |ui| { ui.label( egui::RichText::new(format!("Brush: {}", mode.label())) .color(egui::Color32::from_rgb(170, 220, 255)) .small(), ); }); }); } fn scene_view_gi_badge(world: &World, ctx: &egui::Context, scene_rect: egui::Rect) { let Some(profile) = world.get_resource::() else { return; }; let (label, color) = match profile.gi_path { settings::GiPath::Forward => ("Forward", egui::Color32::from_rgb(140, 180, 255)), settings::GiPath::SolariDeferred => ("Solari", egui::Color32::from_rgb(255, 170, 90)), }; egui::Area::new(egui::Id::new("scene_view_gi_badge")) .fixed_pos(scene_rect.right_bottom() + egui::vec2(-148.0, -22.0)) .interactable(false) .show(ctx, |ui| { egui::Frame::NONE .fill(egui::Color32::from_black_alpha(160)) .inner_margin(egui::Margin::symmetric(8, 4)) .show(ui, |ui| { ui.colored_label(color, label); }); }); } fn scene_view_volume_hud(world: &World, ctx: &egui::Context, scene_rect: egui::Rect) { let Some(contributions) = world.get_resource::() else { return; }; let Some(primary) = contributions.entries.first() else { return; }; egui::Area::new(egui::Id::new("scene_view_volume_hud")) .fixed_pos(scene_rect.left_bottom() + egui::vec2(8.0, -56.0)) .interactable(false) .show(ctx, |ui| { egui::Frame::NONE .fill(egui::Color32::from_black_alpha(150)) .inner_margin(egui::Margin::symmetric(8, 4)) .show(ui, |ui| { ui.label(format!( "Volume: {} ({:.0}%, {} overrides)", primary.label, primary.weight * 100.0, primary.override_count )); }); }); } fn scene_view_overlay_toolbar(world: &mut World, ctx: &egui::Context, scene_rect: egui::Rect) { let mut options_open = world.resource::().options_open; let mut shortcuts_open = world.resource::().shortcuts_open; egui::Area::new(egui::Id::new("scene_view_toolbar")) .fixed_pos(scene_rect.left_top() + egui::vec2(6.0, 6.0)) .interactable(true) .show(ctx, |ui| { viewport_toolbar_frame().show(ui, |ui| { panel_toolbar_row(ui, |ui| { let gizmo_mode = *world.resource::(); if tool_button( ui, icons::ARROWS_OUT_CARDINAL, gizmo_mode == EditorGizmoMode::Translate, "Translate (W)", ) .clicked() { super::helpers::set_gizmo_mode(world, EditorGizmoMode::Translate); } if tool_button( ui, icons::ARROWS_CLOCKWISE, gizmo_mode == EditorGizmoMode::Rotate, "Rotate (E)", ) .clicked() { super::helpers::set_gizmo_mode(world, EditorGizmoMode::Rotate); } if tool_button( ui, icons::ARROWS_IN_LINE_HORIZONTAL, gizmo_mode == EditorGizmoMode::Scale, "Scale (R)", ) .clicked() { super::helpers::set_gizmo_mode(world, EditorGizmoMode::Scale); } ui.separator(); let space = *world.resource::(); if tool_button( ui, icons::GLOBE, space == EditorGizmoSpace::World, "World space (X)", ) .clicked() { super::helpers::set_gizmo_space(world, EditorGizmoSpace::World); } if tool_button( ui, icons::CUBE, space == EditorGizmoSpace::Local, "Local space (X)", ) .clicked() { super::helpers::set_gizmo_space(world, EditorGizmoSpace::Local); } ui.separator(); let viewport_mode = *world.resource::(); if tool_button( ui, icons::SUN, viewport_mode == EditorViewportMode::Lit, "Lit shading (WYSIWYG)", ) .clicked() { *world.resource_mut::() = EditorViewportMode::Lit; } if tool_button( ui, icons::PAINT_BRUSH, viewport_mode == EditorViewportMode::Unlit, "Unlit base color (debug albedo)", ) .clicked() { *world.resource_mut::() = EditorViewportMode::Unlit; } if tool_button( ui, icons::BOUNDING_BOX, viewport_mode == EditorViewportMode::Collider, "Collider view (hide meshes)", ) .clicked() { *world.resource_mut::() = EditorViewportMode::Collider; } ui.separator(); if let Some(mut settings) = world.get_resource_mut::() { let grid = settings.show_grid; if tool_button(ui, icons::GRID_FOUR, grid, "Toggle grid (Ctrl+G)").clicked() { settings.show_grid = !settings.show_grid; } let snap = settings.snap_translate; if tool_button(ui, icons::MAGNET, snap, "Snap translation").clicked() { settings.snap_translate = !settings.snap_translate; } } if let Some(mut visualizers) = world.get_resource_mut::() { let enabled = visualizers.enabled; if tool_button(ui, icons::EYE, enabled, "Toggle scene visualizers") .clicked() { visualizers.enabled = !visualizers.enabled; } } let clean_game_view = world.resource::().clean_game_view; if tool_button( ui, icons::GAME_CONTROLLER, clean_game_view, "Game view overlay (G)", ) .clicked() { world.resource_mut::().clean_game_view = !clean_game_view; } if icon_button_small(ui, icons::CROSSHAIR, "Focus selection (F)").clicked() { focus_editor_camera_on_selection(world); } if icon_button_small(ui, icons::GEAR, "Viewport options").clicked() { options_open = !options_open; } if icon_button_small(ui, icons::QUESTION, VIEWPORT_TOOLTIP).clicked() { shortcuts_open = !shortcuts_open; } }); }); }); if options_open { scene_options_popover(world, ctx, scene_rect, &mut options_open); } if shortcuts_open { viewport_shortcuts_window(ctx, &mut shortcuts_open); } world.resource_mut::().options_open = options_open; world.resource_mut::().shortcuts_open = shortcuts_open; } fn scene_options_popover( world: &mut World, ctx: &egui::Context, scene_rect: egui::Rect, open: &mut bool, ) { egui::Area::new(egui::Id::new("scene_view_options")) .fixed_pos(scene_rect.left_top() + egui::vec2(6.0, 44.0)) .show(ctx, |ui| { viewport_toolbar_frame().show(ui, |ui| { ui.set_min_width(220.0); ui.heading("Viewport Options"); if let Some(mut display) = world.get_resource_mut::() { ui.checkbox(&mut display.clean_game_view, "Game view overlay"); ui.small("G toggles game view; Ctrl+G toggles grid"); } if let Some(mut settings) = world.get_resource_mut::() { ui.separator(); ui.checkbox(&mut settings.show_grid, "Show grid"); ui.checkbox(&mut settings.snap_translate, "Snap translation"); ui.add( egui::Slider::new(&mut settings.snap_increment, 0.25..=4.0) .text("Snap step"), ); } if let Some(mut gizmo_options) = world.get_resource_mut::() { ui.separator(); ui.add( egui::Slider::new(&mut gizmo_options.visuals.gizmo_size, 40.0..=160.0) .text("Gizmo size"), ); } if let Some(mut mode) = world.get_resource_mut::() { ui.separator(); ui.label("Shading"); ui.horizontal(|ui| { ui.selectable_value(&mut *mode, EditorViewportMode::Lit, "Lit"); ui.selectable_value(&mut *mode, EditorViewportMode::Unlit, "Unlit"); ui.selectable_value(&mut *mode, EditorViewportMode::Collider, "Colliders"); }); } let mut actor_icons_active = false; if let Some(mut visualizers) = world.get_resource_mut::() { ui.separator(); ui.checkbox(&mut visualizers.enabled, "Show visualizers"); ui.add_enabled_ui(visualizers.enabled, |ui| { ui.checkbox(&mut visualizers.show_actor_icons, "Actor root icons"); ui.add_enabled_ui(visualizers.show_actor_icons, |ui| { ui.horizontal_wrapped(|ui| { ui.checkbox(&mut visualizers.show_actor_icon_actor, "Actors"); ui.checkbox(&mut visualizers.show_actor_icon_mesh, "Meshes"); ui.checkbox(&mut visualizers.show_actor_icon_light, "Lights"); ui.checkbox(&mut visualizers.show_actor_icon_physics, "Physics"); ui.checkbox(&mut visualizers.show_actor_icon_gameplay, "Gameplay"); ui.checkbox(&mut visualizers.show_actor_icon_volume, "Volumes"); ui.checkbox( &mut visualizers.show_actor_icon_prefab_model, "Prefabs/models", ); ui.checkbox(&mut visualizers.show_actor_icon_debug, "Warnings"); }); }); actor_icons_active = visualizers.enabled && visualizers.show_actor_icons; ui.separator(); ui.checkbox(&mut visualizers.show_colliders, "Colliders"); ui.checkbox(&mut visualizers.show_lights, "Lights"); ui.checkbox(&mut visualizers.show_spawns, "Player spawns"); ui.checkbox( &mut visualizers.show_prefab_model_anchors, "Prefab/model anchors", ); ui.checkbox(&mut visualizers.show_runtime, "Runtime player/cameras"); ui.checkbox(&mut visualizers.pickable_proxies, "Selectable proxies"); }); } if actor_icons_active { if let Some(mut icon_settings) = world.get_resource_mut::() { ui.add( egui::Slider::new( &mut icon_settings.size_pixels, ActorIconSettings::MIN_SIZE_PIXELS ..=ActorIconSettings::MAX_SIZE_PIXELS, ) .text("Icon size"), ); } } ui.horizontal(|ui| { if ui.button("Save bookmark").clicked() { viewport_save_bookmark(world); } if ui.button("Recall bookmark").clicked() { viewport_recall_bookmark(world); } }); ui.small("Ctrl+Shift+1 save | Ctrl+Shift+2 recall"); if ui.button("Close").clicked() { *open = false; } }); }); } fn viewport_shortcuts_window(ctx: &egui::Context, open: &mut bool) { egui::Window::new("Viewport Shortcuts") .open(open) .default_width(320.0) .show(ctx, |ui| { ui.label("Viewport navigation"); ui.small("RMB + WASD/QE - fly camera"); ui.small("MMB drag - pan"); ui.small("Scroll - dolly"); ui.separator(); ui.label("Tools"); ui.small("W / E / R - translate / rotate / scale gizmo"); ui.small("X - toggle world / local space"); ui.small("G - toggle game view overlay"); ui.small("Ctrl+G - toggle grid"); ui.small("F - focus selection"); ui.small("Tab - cycle overlapping selection at click"); ui.small("F5 - play / stop (main toolbar)"); ui.small("F6 - pause / resume (while playing)"); ui.small("F8 - possess / eject (while playing)"); ui.small("Esc - deselect / free cursor"); }); } fn game_view_overlay(world: &World, ctx: &egui::Context, rect: egui::Rect, playing: bool) { egui::Area::new(egui::Id::new("game_view_hud")) .fixed_pos(rect.left_top() + egui::vec2(6.0, 6.0)) .interactable(false) .show(ctx, |ui| { viewport_toolbar_frame().show(ui, |ui| { if !playing { ui.label(egui::RichText::new("Game View").strong()); ui.small("Press F5 or Play to run here."); return; } ui.label(egui::RichText::new("Playing").strong()); let possession = *world.resource::(); let possess_label = match possession { PlayPossession::Possessed => "Possessed", PlayPossession::Ejected => "Ejected (Scene camera)", }; ui.small(possess_label); ui.small("F5 stop | F6 pause | F8 possess/eject | Esc free cursor"); }); }); } fn viewport_asset_drop_ui( world: &mut World, ui: &mut egui::Ui, viewport_rect: egui::Rect, selected: &SelectedEntities, ) { let pointer_released = ui.input(|input| input.pointer.any_released()); if !pointer_released { return; } let pointer_pos = ui.input(|input| input.pointer.hover_pos()); if !pointer_pos.is_some_and(|pos| viewport_rect.contains(pos)) { world.resource_mut::().clear_drag(); return; } let selection = { let assets = world.resource::(); assets.dragging_selection().cloned() }; world.resource_mut::().clear_drag(); let Some(selection) = selection else { return; }; let placement = viewport_ground_position(world, pointer_pos.unwrap(), viewport_rect) .map(|pos| snap_translation(pos, world.resource::())) .unwrap_or(Vec3::ZERO); if matches!( selection, AssetSelection::SubAsset { kind: AssetSubAssetKind::Mesh, .. } ) { place_subasset_operator(world, selection, placement); return; } if let AssetSelection::SubAsset { kind: AssetSubAssetKind::Texture, source_path: Some(path), label, .. } = &selection { let asset = crate::assets::EditorAsset { label: label.clone(), path: Some(path.clone()), folder_path: std::path::Path::new(path) .parent() .map(|parent| parent.to_string_lossy().replace('\\', "/")) .unwrap_or_else(|| crate::assets::ASSETS_ROOT.to_string()), kind: crate::assets::EditorAssetKind::Texture, }; apply_texture_operator(world, asset, selected); return; } let asset = { let assets = world.resource::(); assets.asset_for_selection(&selection).cloned() }; let Some(asset) = asset else { return; }; match asset.kind { crate::assets::EditorAssetKind::Texture => { apply_texture_operator(world, asset, selected); return; } crate::assets::EditorAssetKind::Material => { apply_material_operator(world, asset, selected); return; } _ => {} } place_asset_operator(world, asset, placement); }