//! 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, assign_animation_clip_operator, assign_audio_clip_operator, place_asset_operator, place_subasset_operator, }; use crate::assets::thumbnails::kind_icon; use crate::assets::{AssetSelection, AssetSubAssetKind, EditorAssets}; use crate::camera::EditorCamera; use crate::gizmos::{EditorGizmoMode, EditorGizmoSpace}; use crate::render_target::ViewportRenderTarget; use crate::selection::{ViewportClick, ViewportPickStack}; use crate::state::PlayPossession; use crate::viewport::actor_icons::ActorIconSettings; use crate::viewport::brush_edit::{BrushEditMode, BrushElementSelection}; use crate::viewport::brush_tool::{BrushToolPhase, BrushToolState}; 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::{ overlay_chip_frame, viewport_toolbar_frame, ACCENT, ACCENT_HOVER, AXIS_X, AXIS_Y, AXIS_Z, BORDER, BORDER_STRONG, ELEVATED_BG, SELECTION, SUCCESS, TEXT, TEXT_DIM, TEXT_MUTED, WARNING, }; use super::widgets::{ icon_button_small, panel_toolbar_row, phosphor_icon, tool_button, tool_button_accent, }; #[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)"; #[expect( clippy::too_many_arguments, reason = "viewport tab rendering keeps immediate-mode UI inputs explicit" )] 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, ); painter.rect_stroke( rect, 0.0, egui::Stroke::new( 1.0, if response.hovered() { BORDER_STRONG } else { BORDER }, ), egui::StrokeKind::Inside, ); } 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_selection_hud(world, ui.ctx(), rect, selected_entities); scene_view_brush_draw_hints(world, ui.ctx(), rect); scene_view_render_badge(world, ui.ctx(), rect); scene_view_brush_mode_badge(world, ui.ctx(), rect); scene_view_volume_hud(world, ui.ctx(), rect); scene_view_orientation_widget(world, ui.ctx(), rect); viewport_asset_drop_ui(world, ui, rect, selected_entities); } fn scene_view_render_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", SUCCESS), EditorViewportMode::Unlit => ("Unlit", WARNING), EditorViewportMode::Collider => ("Collider", WARNING), }; let profile = world.get_resource::(); let (gi_label, gi_color) = match profile.map(|profile| profile.gi_path) { Some(settings::GiPath::SolariDeferred) => ("Solari", ACCENT), _ => ("Forward", SELECTION), }; egui::Area::new(egui::Id::new("scene_view_render_badge")) .fixed_pos(scene_rect.right_bottom() + egui::vec2(-10.0, -10.0)) .pivot(egui::Align2::RIGHT_BOTTOM) .interactable(false) .show(ctx, |ui| { overlay_chip_frame().show(ui, |ui| { ui.horizontal(|ui| { ui.label(egui::RichText::new(label).color(color).strong().small()); ui.label(egui::RichText::new("/").color(TEXT_MUTED).small()); ui.label( egui::RichText::new(gi_label) .color(gi_color) .strong() .small(), ); }); }); }); 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| { overlay_chip_frame().show(ui, |ui| { ui.label( egui::RichText::new("Collider view / meshes hidden") .color(WARNING) .small(), ); }); }); } } 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; } let selected_count = world .get_resource::() .map(|selection| selection.elements.len()) .unwrap_or(0); egui::Area::new(egui::Id::new("scene_view_brush_mode_badge")) .fixed_pos(scene_rect.left_top() + egui::vec2(8.0, 52.0)) .interactable(false) .show(ctx, |ui| { overlay_chip_frame().show(ui, |ui| { ui.horizontal(|ui| { ui.label( egui::RichText::new(format!("Brush {}", mode.label())) .color(ACCENT) .strong(), ); ui.label( egui::RichText::new(format!("{selected_count} selected")) .color(SELECTION) .small(), ); ui.label(egui::RichText::new("Element gizmo").color(SUCCESS).small()); }); ui.add_space(4.0); ui.horizontal_wrapped(|ui| { match mode { BrushEditMode::Vertex => key_hint(ui, "LMB", "Select vertex"), BrushEditMode::Edge => key_hint(ui, "LMB", "Select edge"), BrushEditMode::Face => key_hint(ui, "LMB", "Select face"), BrushEditMode::Clip => key_hint(ui, "LMB", "Select face"), BrushEditMode::Object => {} } if !matches!(mode, BrushEditMode::Clip) { key_hint(ui, "W/E/R", "Move/rotate/scale"); } if matches!(mode, BrushEditMode::Clip) { key_hint(ui, "Enter", "Clip"); } key_hint(ui, "Shift+LMB", "Multi"); key_hint(ui, "Esc", "Object"); }); }); }); } fn scene_view_brush_draw_hints(world: &World, ctx: &egui::Context, scene_rect: egui::Rect) { let Some(tool) = world.get_resource::() else { return; }; if !tool.active { return; } let hint = match tool.phase { BrushToolPhase::Outline if tool.vertices.len() < 3 => "Place points".to_string(), BrushToolPhase::Outline => "Set height".to_string(), BrushToolPhase::Height => format!("{:.1}m", tool.height), }; egui::Area::new(egui::Id::new("scene_view_brush_draw_hints")) .fixed_pos(scene_rect.left_top() + egui::vec2(8.0, 52.0)) .interactable(false) .show(ctx, |ui| { overlay_chip_frame().show(ui, |ui| { ui.horizontal(|ui| { ui.label(egui::RichText::new("Draw Brush").color(SELECTION).strong()); ui.label( egui::RichText::new(format!("{} pts", tool.vertices.len())) .color(TEXT_DIM) .small(), ); ui.label(egui::RichText::new(hint).color(SUCCESS).small()); }); ui.add_space(4.0); ui.horizontal_wrapped(|ui| match tool.phase { BrushToolPhase::Outline => { key_hint(ui, "LMB", "Point"); key_hint(ui, "Enter", "Height"); key_hint(ui, "Backspace", "Remove"); key_hint(ui, "Esc", "Cancel"); key_hint(ui, "RMB", "Cancel"); } BrushToolPhase::Height => { key_hint(ui, "Mouse Up/Down", "Height"); key_hint(ui, "Enter", "Create"); key_hint(ui, "LMB", "Create"); key_hint(ui, "Backspace", "Outline"); key_hint(ui, "Esc", "Cancel"); key_hint(ui, "RMB", "Cancel"); } }); }); }); } fn key_hint(ui: &mut egui::Ui, key: &str, label: &str) { ui.horizontal(|ui| { ui.label( egui::RichText::new(key) .monospace() .color(TEXT) .background_color(ELEVATED_BG), ); ui.label(egui::RichText::new(label).color(TEXT_DIM).small()); }); } fn scene_view_selection_hud( world: &World, ctx: &egui::Context, scene_rect: egui::Rect, selected_entities: &SelectedEntities, ) { let Some(&primary) = selected_entities.as_slice().first() else { return; }; let name = world .get::(primary) .map(|name| name.as_str().to_string()) .unwrap_or_else(|| "Selected entity".to_string()); let kind = world .get::(primary) .map(|kind| format!("{kind:?}")) .unwrap_or_else(|| "Entity".to_string()); let count = selected_entities.len(); let selection_detail = if count > 1 { format!("{kind} / {count} selected") } else { kind }; let pick_detail = world.get_resource::().and_then(|stack| { (stack.entities.len() > 1 && stack.entities.contains(&primary)).then(|| { format!( "Pick {}/{}", stack.index.min(stack.entities.len() - 1) + 1, stack.entities.len() ) }) }); let top_offset = if scene_rect.width() < 1100.0 { 50.0 } else { 10.0 }; egui::Area::new(egui::Id::new("scene_view_selection_hud")) .fixed_pos(scene_rect.right_top() + egui::vec2(-10.0, top_offset)) .pivot(egui::Align2::RIGHT_TOP) .interactable(false) .show(ctx, |ui| { overlay_chip_frame().show(ui, |ui| { ui.horizontal(|ui| { ui.label(phosphor_icon(icons::CROSSHAIR, 17.0).color(SELECTION)); ui.vertical(|ui| { ui.set_max_width(260.0); ui.add( egui::Label::new(egui::RichText::new(&name).color(TEXT).strong()) .truncate(), ); ui.label( egui::RichText::new(&selection_detail) .color(TEXT_DIM) .small(), ); }); if let Some(pick_detail) = pick_detail { ui.separator(); ui.label( egui::RichText::new(pick_detail) .color(ACCENT) .monospace() .small(), ); } }); }); }); } fn scene_view_orientation_widget(world: &mut World, ctx: &egui::Context, scene_rect: egui::Rect) { let rotation = { let mut cameras = world.query_filtered::<(&Camera, &GlobalTransform), With>(); let mut rotation = None; for (camera, transform) in cameras.iter(world) { rotation.get_or_insert(transform.rotation()); if camera.is_active { rotation = Some(transform.rotation()); break; } } rotation }; let Some(rotation) = rotation else { return; }; egui::Area::new(egui::Id::new("scene_view_orientation_widget")) .fixed_pos(scene_rect.right_bottom() + egui::vec2(-10.0, -48.0)) .pivot(egui::Align2::RIGHT_BOTTOM) .interactable(false) .show(ctx, |ui| { overlay_chip_frame().show(ui, |ui| { let (rect, _) = ui.allocate_exact_size(egui::vec2(72.0, 72.0), egui::Sense::hover()); let painter = ui.painter_at(rect); let center = rect.center(); painter.circle_filled(center, 4.0, egui::Color32::from_rgb(210, 214, 219)); let view_rotation = rotation.inverse(); let mut axes = [ ("X", view_rotation * Vec3::X, AXIS_X), ("Y", view_rotation * Vec3::Y, AXIS_Y), ("Z", view_rotation * Vec3::Z, AXIS_Z), ]; axes.sort_by(|left, right| { left.1 .z .partial_cmp(&right.1.z) .unwrap_or(std::cmp::Ordering::Equal) }); for (label, axis, color) in axes { let endpoint = center + egui::vec2(axis.x, -axis.y) * 27.0; let facing = axis.z <= 0.0; let color = if facing { color } else { color.gamma_multiply(0.48) }; painter.line_segment( [center, endpoint], egui::Stroke::new(if facing { 2.2 } else { 1.2 }, color), ); painter.circle_filled(endpoint, if facing { 9.0 } else { 7.0 }, color); painter.text( endpoint, egui::Align2::CENTER_CENTER, label, egui::FontId::monospace(10.0), egui::Color32::from_rgb(18, 20, 22), ); } }); }); } 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| { overlay_chip_frame().show(ui, |ui| { ui.horizontal(|ui| { ui.label(phosphor_icon(icons::CUBE, 14.0).color(SELECTION)); ui.label( egui::RichText::new(&primary.label) .color(TEXT) .strong() .small(), ); ui.label( egui::RichText::new(format!( "{:.0}% / {} overrides", primary.weight * 100.0, primary.override_count )) .color(TEXT_DIM) .small(), ); }); }); }); } 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_accent( ui, icons::ARROWS_OUT_CARDINAL, gizmo_mode == EditorGizmoMode::Translate, "Translate (W)", ) .clicked() { super::helpers::set_gizmo_mode(world, EditorGizmoMode::Translate); } if tool_button_accent( ui, icons::ARROWS_CLOCKWISE, gizmo_mode == EditorGizmoMode::Rotate, "Rotate (E)", ) .clicked() { super::helpers::set_gizmo_mode(world, EditorGizmoMode::Rotate); } if tool_button_accent( 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 settings.snap_translate { ui.label( egui::RichText::new(format!("{:.2}m", settings.snap_increment)) .color(SELECTION) .monospace() .small(), ); } } 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") .suffix(" m"), ); } 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_audio, "Audio"); 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_audio, "Audio ranges"); ui.checkbox(&mut visualizers.show_spawns, "Player spawns"); ui.checkbox(&mut visualizers.show_gameplay, "Gameplay markers"); ui.checkbox(&mut visualizers.show_volumes, "Volumes"); 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_pos = ui.input(|input| input.pointer.hover_pos()); let drag_selection = world .resource::() .dragging_selection() .cloned(); let drag_descriptor = drag_selection .as_ref() .and_then(|selection| asset_drag_descriptor(world, selection, selected)); let over_viewport = pointer_pos.is_some_and(|pos| viewport_rect.contains(pos)); if let (Some(pointer), Some((icon, label, action))) = (pointer_pos, drag_descriptor.as_ref()) { draw_asset_drag_preview( ui, viewport_rect, pointer, *icon, label, action, over_viewport, ); } if drag_selection.is_some() && over_viewport { ui.painter().rect_stroke( viewport_rect.shrink(2.0), 0.0, egui::Stroke::new(2.0, ACCENT_HOVER), egui::StrokeKind::Inside, ); } let pointer_released = ui.input(|input| input.pointer.any_released()); if !pointer_released { return; } if !pointer_pos.is_some_and(|pos| viewport_rect.contains(pos)) { world.resource_mut::().clear_drag(); return; } world.resource_mut::().clear_drag(); let Some(selection) = drag_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::AnimationClip, .. } ) { if let Some(entity) = selected .as_slice() .iter() .copied() .find(|entity| world.get::(*entity).is_some()) { assign_animation_clip_operator(world, selection, entity); } else { place_subasset_operator(world, selection, placement); } return; } if matches!( selection, AssetSelection::SubAsset { kind: AssetSubAssetKind::Mesh, .. } ) { place_subasset_operator(world, selection, placement); return; } if matches!( selection, AssetSelection::SubAsset { kind: AssetSubAssetKind::Skeleton, .. } ) { 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; }; if matches!(asset.kind, crate::assets::EditorAssetKind::AudioClip) { if let Some(entity) = selected .as_slice() .iter() .copied() .find(|entity| world.get::(*entity).is_some()) { assign_audio_clip_operator(world, asset, entity); } else { place_asset_operator(world, asset, placement); } 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); } fn asset_drag_descriptor( world: &World, selection: &AssetSelection, selected: &SelectedEntities, ) -> Option<(egui_phosphor_icons::Icon, String, String)> { let assets = world.resource::(); let asset = assets.asset_for_selection(selection)?; let icon = match selection { AssetSelection::SubAsset { kind: AssetSubAssetKind::Mesh, .. } => icons::CUBE, AssetSelection::SubAsset { kind: AssetSubAssetKind::Material, .. } => icons::PAINT_BRUSH, AssetSelection::SubAsset { kind: AssetSubAssetKind::Texture, .. } => icons::IMAGE, AssetSelection::SubAsset { kind: AssetSubAssetKind::Skeleton, .. } => icons::BONE, AssetSelection::SubAsset { kind: AssetSubAssetKind::AnimationClip, .. } => icons::FILM_STRIP, _ => kind_icon(&asset.kind), }; let label = match selection { AssetSelection::SubAsset { label, .. } => label.clone(), _ => asset.label.clone(), }; let action = match selection { AssetSelection::SubAsset { kind: AssetSubAssetKind::Texture, .. } if !selected.is_empty() => "Apply texture to selection", AssetSelection::SubAsset { kind: AssetSubAssetKind::Material, .. } if !selected.is_empty() => "Apply material to selection", AssetSelection::SubAsset { kind: AssetSubAssetKind::Skeleton, .. } => "Inspect skeleton", AssetSelection::SubAsset { kind: AssetSubAssetKind::AnimationClip, .. } if selected .as_slice() .iter() .any(|entity| world.get::(*entity).is_some()) => { "Assign clip to animated actor" } AssetSelection::SubAsset { kind: AssetSubAssetKind::AnimationClip, .. } => "Create animated actor", _ => match asset.kind { crate::assets::EditorAssetKind::Texture if !selected.is_empty() => { "Apply texture to selection" } crate::assets::EditorAssetKind::Material if !selected.is_empty() => { "Apply material to selection" } crate::assets::EditorAssetKind::AudioClip if selected .as_slice() .iter() .any(|entity| world.get::(*entity).is_some()) => { "Assign clip to audio source" } crate::assets::EditorAssetKind::AudioClip => "Create audio source", _ => "Place in scene", }, }; Some((icon, label, action.to_string())) } fn draw_asset_drag_preview( ui: &egui::Ui, viewport_rect: egui::Rect, pointer: egui::Pos2, icon: egui_phosphor_icons::Icon, label: &str, action: &str, over_viewport: bool, ) { if over_viewport { let painter = ui.painter(); painter.circle_filled(pointer, 3.0, ACCENT_HOVER); painter.circle_stroke(pointer, 11.0, egui::Stroke::new(1.5, ACCENT_HOVER)); painter.line_segment( [ pointer - egui::vec2(17.0, 0.0), pointer - egui::vec2(7.0, 0.0), ], egui::Stroke::new(1.5, ACCENT_HOVER), ); painter.line_segment( [ pointer + egui::vec2(7.0, 0.0), pointer + egui::vec2(17.0, 0.0), ], egui::Stroke::new(1.5, ACCENT_HOVER), ); painter.line_segment( [ pointer - egui::vec2(0.0, 17.0), pointer - egui::vec2(0.0, 7.0), ], egui::Stroke::new(1.5, ACCENT_HOVER), ); painter.line_segment( [ pointer + egui::vec2(0.0, 7.0), pointer + egui::vec2(0.0, 17.0), ], egui::Stroke::new(1.5, ACCENT_HOVER), ); } let content = ui.ctx().content_rect(); let pos = egui::pos2( (pointer.x + 20.0).min(content.right() - 258.0), (pointer.y + 20.0).min(content.bottom() - 64.0), ); egui::Area::new(egui::Id::new("asset_drag_preview")) .fixed_pos(pos) .default_size(egui::vec2(246.0, 60.0)) .order(egui::Order::Tooltip) .interactable(false) .show(ui.ctx(), |ui| { ui.set_min_size(egui::vec2(246.0, 60.0)); ui.set_max_size(egui::vec2(246.0, 60.0)); overlay_chip_frame().show(ui, |ui| { ui.set_width(228.0); ui.horizontal(|ui| { ui.label(phosphor_icon(icon, 20.0).color(if over_viewport { ACCENT_HOVER } else { TEXT_DIM })); ui.vertical(|ui| { ui.spacing_mut().item_spacing.y = 1.0; ui.label(egui::RichText::new(label).strong().color(TEXT)); ui.label( egui::RichText::new(if over_viewport { action } else { "Drag into the viewport" }) .size(11.0) .color(if over_viewport { ACCENT_HOVER } else { TEXT_MUTED }), ); }); }); }); }); if over_viewport { let corner = 16.0; let inset = viewport_rect.shrink(8.0); for (a, b, c) in [ ( inset.left_top(), egui::vec2(corner, 0.0), egui::vec2(0.0, corner), ), ( inset.right_top(), egui::vec2(-corner, 0.0), egui::vec2(0.0, corner), ), ( inset.left_bottom(), egui::vec2(corner, 0.0), egui::vec2(0.0, -corner), ), ( inset.right_bottom(), egui::vec2(-corner, 0.0), egui::vec2(0.0, -corner), ), ] { ui.painter() .line_segment([a, a + b], egui::Stroke::new(3.0, ACCENT_HOVER)); ui.painter() .line_segment([a, a + c], egui::Stroke::new(3.0, ACCENT_HOVER)); } } }