Blacksite/crates/editor/src/ui/viewport_chrome.rs
Rbanh 0bfc062ba7
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Add brush element edit modes
2026-06-06 05:57:50 -04:00

623 lines
25 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, 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<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,
);
} 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::<EditorViewportMode>() 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::<BrushEditMode>().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::<settings::ActiveCameraRenderProfile>() 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::<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| {
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::<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(
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::<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 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"),
);
}
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_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::<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_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::<EditorAssets>().clear_drag();
return;
}
let selection = {
let assets = world.resource::<EditorAssets>();
assets.dragging_selection().cloned()
};
world.resource_mut::<EditorAssets>().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::<ViewportSettings>()))
.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::<EditorAssets>();
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);
}