Blacksite/crates/editor/src/ui/viewport_chrome.rs
Rbanh 0798aa5d57 Build renderer and material component foundations
Add dedicated skinned rendering, pose restoration, shared Material and Material Instance slots, registry-driven components, Surface/Solari integration, transactional schema upgrades, navigation authoring, documentation, and evaluation evidence.
2026-07-12 00:24:06 -04:00

1161 lines
44 KiB
Rust

//! 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<egui::Pos2>,
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::<ViewportDisplayMode>().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::<ViewportClick>().0 = Some(pos);
}
}
if let Some(target) = world
.get_resource::<ViewportRenderTarget>()
.and_then(|target| target.0.clone())
{
let mut textures = world.resource_mut::<EguiUserTextures>();
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::<EditorViewportMode>() 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::<settings::ActiveCameraRenderProfile>();
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::<BrushEditMode>().copied() else {
return;
};
if !mode.is_element_mode() {
return;
}
let selected_count = world
.get_resource::<BrushElementSelection>()
.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::<BrushToolState>() 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::<Name>(primary)
.map(|name| name.as_str().to_string())
.unwrap_or_else(|| "Selected entity".to_string());
let kind = world
.get::<shared::ActorKind>(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::<ViewportPickStack>().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<EditorCamera>>();
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::<settings::ActiveVolumeContribution>() 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::<ViewportUiState>().options_open;
let mut shortcuts_open = world.resource::<ViewportUiState>().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::<crate::gizmos::EditorGizmoMode>();
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::<EditorGizmoSpace>();
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::<EditorViewportMode>();
if tool_button(
ui,
icons::SUN,
viewport_mode == EditorViewportMode::Lit,
"Lit shading (WYSIWYG)",
)
.clicked()
{
*world.resource_mut::<EditorViewportMode>() = EditorViewportMode::Lit;
}
if tool_button(
ui,
icons::PAINT_BRUSH,
viewport_mode == EditorViewportMode::Unlit,
"Unlit base color (debug albedo)",
)
.clicked()
{
*world.resource_mut::<EditorViewportMode>() = EditorViewportMode::Unlit;
}
if tool_button(
ui,
icons::BOUNDING_BOX,
viewport_mode == EditorViewportMode::Collider,
"Collider view (hide meshes)",
)
.clicked()
{
*world.resource_mut::<EditorViewportMode>() = EditorViewportMode::Collider;
}
ui.separator();
if let Some(mut settings) = world.get_resource_mut::<ViewportSettings>() {
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::<crate::visualizers::EditorVisualizationSettings>()
{
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::<ViewportDisplayMode>().clean_game_view;
if tool_button(
ui,
icons::GAME_CONTROLLER,
clean_game_view,
"Game view overlay (G)",
)
.clicked()
{
world.resource_mut::<ViewportDisplayMode>().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::<ViewportUiState>().options_open = options_open;
world.resource_mut::<ViewportUiState>().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::<ViewportDisplayMode>() {
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::<ViewportSettings>() {
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::<GizmoOptions>() {
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::<EditorViewportMode>() {
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::<crate::visualizers::EditorVisualizationSettings>()
{
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::<ActorIconSettings>() {
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::<PlayPossession>();
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::<EditorAssets>()
.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::<EditorAssets>().clear_drag();
return;
}
world.resource_mut::<EditorAssets>().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::<ViewportSettings>()))
.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::<shared::SkinnedMeshRenderer>(*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::<EditorAssets>();
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::<shared::AudioSourceDesc>(*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::<EditorAssets>();
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::<shared::SkinnedMeshRenderer>(*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::<shared::AudioSourceDesc>(*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));
}
}
}