1487 lines
59 KiB
Rust
1487 lines
59 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::{
|
|
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::terrain_paint::{TerrainPaintMode, TerrainPaintState};
|
|
use crate::viewport::terrain_sculpt::{TerrainSculptMode, TerrainSculptState};
|
|
use crate::viewport::{
|
|
material_drop::is_surface_asset_selection, snap_translation, viewport_ground_position,
|
|
EditorViewportMode, MaterialDropState, 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, ERROR, 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,
|
|
pub toolbar_rect: Option<egui::Rect>,
|
|
}
|
|
|
|
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);
|
|
// A drag source owns egui's interaction response, so `Response::hover_pos()`
|
|
// is intentionally empty while an asset is dragged over the viewport. The
|
|
// global pointer position remains available and is the correct input for
|
|
// drag previews and exact surface targeting.
|
|
*viewport_pointer_pos = ui
|
|
.input(|input| {
|
|
input
|
|
.pointer
|
|
.interact_pos()
|
|
.or_else(|| input.pointer.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_terrain_sculpt_hints(world, ui.ctx(), rect);
|
|
scene_view_terrain_paint_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 scene_view_terrain_sculpt_hints(world: &World, ctx: &egui::Context, scene_rect: egui::Rect) {
|
|
let Some(tool) = world.get_resource::<TerrainSculptState>() else {
|
|
return;
|
|
};
|
|
if !tool.active {
|
|
return;
|
|
}
|
|
egui::Area::new(egui::Id::new("scene_view_terrain_sculpt_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(format!("Terrain {}", tool.mode.label()))
|
|
.color(SELECTION)
|
|
.strong(),
|
|
);
|
|
ui.label(
|
|
egui::RichText::new(format!("R {:.1}m", tool.radius))
|
|
.color(TEXT_DIM)
|
|
.monospace()
|
|
.small(),
|
|
);
|
|
ui.label(
|
|
egui::RichText::new(format!("S {:.2}m", tool.strength))
|
|
.color(SUCCESS)
|
|
.monospace()
|
|
.small(),
|
|
);
|
|
});
|
|
ui.add_space(4.0);
|
|
ui.horizontal_wrapped(|ui| {
|
|
key_hint(ui, "LMB Drag", "Sculpt");
|
|
key_hint(ui, "Esc", "Cancel stroke / close");
|
|
key_hint(ui, "RMB", "Cancel stroke / close");
|
|
key_hint(ui, "Ctrl+Z", "Undo stroke");
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
fn scene_view_terrain_paint_hints(world: &World, ctx: &egui::Context, scene_rect: egui::Rect) {
|
|
let Some(tool) = world.get_resource::<TerrainPaintState>() else {
|
|
return;
|
|
};
|
|
if !tool.active {
|
|
return;
|
|
}
|
|
egui::Area::new(egui::Id::new("scene_view_terrain_paint_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(format!(
|
|
"Terrain {} • Layer {}",
|
|
tool.mode.label(),
|
|
tool.active_layer + 1
|
|
))
|
|
.color(SELECTION)
|
|
.strong(),
|
|
);
|
|
ui.label(
|
|
egui::RichText::new(format!("R {:.1}m", tool.radius))
|
|
.color(TEXT_DIM)
|
|
.monospace()
|
|
.small(),
|
|
);
|
|
ui.label(
|
|
egui::RichText::new(format!("S {:.0}%", tool.strength * 100.0))
|
|
.color(SUCCESS)
|
|
.monospace()
|
|
.small(),
|
|
);
|
|
});
|
|
ui.add_space(4.0);
|
|
ui.horizontal_wrapped(|ui| {
|
|
key_hint(ui, "LMB Drag", tool.mode.label());
|
|
key_hint(ui, "Esc", "Cancel stroke / close");
|
|
key_hint(ui, "RMB", "Cancel stroke / close");
|
|
key_hint(ui, "Ctrl+Z", "Undo stroke");
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
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;
|
|
let terrain_selected = world
|
|
.resource::<crate::selection::SelectedEntity>()
|
|
.0
|
|
.filter(|entity| world.get::<shared::TerrainDesc>(*entity).is_some());
|
|
let terrain_layer_labels = terrain_selected
|
|
.and_then(|entity| world.get::<shared::TerrainDesc>(entity))
|
|
.map(|terrain| {
|
|
terrain
|
|
.material_layers
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, layer)| {
|
|
layer
|
|
.material
|
|
.as_ref()
|
|
.map(|material| material.label.clone())
|
|
.filter(|label| !label.trim().is_empty())
|
|
.unwrap_or_else(|| format!("Layer {}", index + 1))
|
|
})
|
|
.collect::<Vec<_>>()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
let toolbar_response = 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 sculpt_active = world.resource::<TerrainSculptState>().active;
|
|
let paint_active = world.resource::<TerrainPaintState>().active;
|
|
if terrain_selected.is_some() || sculpt_active || paint_active {
|
|
ui.separator();
|
|
let stroking = world.resource::<TerrainSculptState>().is_stroking();
|
|
let sculpt_button_clicked = tool_button_accent(
|
|
ui,
|
|
icons::MOUNTAINS,
|
|
sculpt_active,
|
|
if sculpt_active {
|
|
"Close terrain sculpt tool"
|
|
} else {
|
|
"Sculpt selected terrain"
|
|
},
|
|
)
|
|
.clicked();
|
|
if sculpt_button_clicked {
|
|
world.resource_mut::<ViewportClick>().0 = None;
|
|
}
|
|
if sculpt_button_clicked && !stroking {
|
|
let state = &mut *world.resource_mut::<TerrainSculptState>();
|
|
if state.active {
|
|
state.stop();
|
|
} else if let Some(entity) = terrain_selected {
|
|
state.start(entity);
|
|
}
|
|
let active = state.active;
|
|
if active {
|
|
world.resource_mut::<TerrainPaintState>().stop();
|
|
world.resource_mut::<BrushToolState>().cancel();
|
|
world
|
|
.resource_mut::<crate::operators::ActiveOperator>()
|
|
.status = Some(crate::operators::OperatorStatus {
|
|
id: "terrain.sculpt".to_string(),
|
|
label: "Terrain Sculpt".to_string(),
|
|
phase: crate::operators::OperatorPhase::Preview,
|
|
hint: "LMB drag sculpts; Esc or RMB cancels".to_string(),
|
|
warnings: Vec::new(),
|
|
});
|
|
}
|
|
}
|
|
if sculpt_active {
|
|
let mut mode = world.resource::<TerrainSculptState>().mode;
|
|
for candidate in [
|
|
TerrainSculptMode::Raise,
|
|
TerrainSculptMode::Lower,
|
|
TerrainSculptMode::Flatten,
|
|
TerrainSculptMode::Smooth,
|
|
TerrainSculptMode::Noise,
|
|
] {
|
|
ui.selectable_value(&mut mode, candidate, candidate.label())
|
|
.on_hover_text(format!("{} terrain", candidate.label()));
|
|
}
|
|
let mut radius = world.resource::<TerrainSculptState>().radius;
|
|
let mut strength = world.resource::<TerrainSculptState>().strength;
|
|
ui.add(
|
|
egui::DragValue::new(&mut radius)
|
|
.range(0.25..=128.0)
|
|
.speed(0.1)
|
|
.prefix("R ")
|
|
.suffix(" m"),
|
|
)
|
|
.on_hover_text("Brush radius");
|
|
ui.add(
|
|
egui::DragValue::new(&mut strength)
|
|
.range(0.01..=20.0)
|
|
.speed(0.05)
|
|
.prefix("S ")
|
|
.suffix(" m"),
|
|
)
|
|
.on_hover_text("Height strength per dab");
|
|
let mut state = world.resource_mut::<TerrainSculptState>();
|
|
state.mode = mode;
|
|
state.radius = radius;
|
|
state.strength = strength;
|
|
state.clamp_settings();
|
|
}
|
|
|
|
let paint_stroking = world.resource::<TerrainPaintState>().is_stroking();
|
|
let can_paint = terrain_layer_labels.len() >= 2 || paint_active;
|
|
let paint_button_clicked = ui
|
|
.add_enabled(
|
|
can_paint,
|
|
egui::Button::new(phosphor_icon(icons::PAINT_BRUSH, 16.0))
|
|
.selected(paint_active),
|
|
)
|
|
.on_hover_text(if can_paint {
|
|
if paint_active {
|
|
"Close terrain material paint tool"
|
|
} else {
|
|
"Paint selected terrain material layers"
|
|
}
|
|
} else {
|
|
"Assign at least two terrain material layers first"
|
|
})
|
|
.clicked();
|
|
if paint_button_clicked {
|
|
world.resource_mut::<ViewportClick>().0 = None;
|
|
}
|
|
if paint_button_clicked && !paint_stroking {
|
|
let layer_count = terrain_layer_labels.len();
|
|
let state = &mut *world.resource_mut::<TerrainPaintState>();
|
|
if state.active {
|
|
state.stop();
|
|
} else if let Some(entity) = terrain_selected {
|
|
state.start(entity, layer_count);
|
|
}
|
|
let active = state.active;
|
|
if active {
|
|
world.resource_mut::<TerrainSculptState>().stop();
|
|
world.resource_mut::<BrushToolState>().cancel();
|
|
world
|
|
.resource_mut::<crate::operators::ActiveOperator>()
|
|
.status = Some(crate::operators::OperatorStatus {
|
|
id: "terrain.paint".into(),
|
|
label: "Terrain Paint".into(),
|
|
phase: crate::operators::OperatorPhase::Preview,
|
|
hint: "LMB drag paints weights; Esc or RMB cancels".into(),
|
|
warnings: Vec::new(),
|
|
});
|
|
}
|
|
}
|
|
if paint_active {
|
|
let mut mode = world.resource::<TerrainPaintState>().mode;
|
|
ui.selectable_value(&mut mode, TerrainPaintMode::Paint, "Paint")
|
|
.on_hover_text("Increase the selected layer weight");
|
|
ui.selectable_value(&mut mode, TerrainPaintMode::Erase, "Erase")
|
|
.on_hover_text("Reduce the selected layer weight");
|
|
let mut active_layer =
|
|
world.resource::<TerrainPaintState>().active_layer;
|
|
egui::ComboBox::from_id_salt("terrain_paint_layer")
|
|
.selected_text(
|
|
terrain_layer_labels
|
|
.get(active_layer)
|
|
.cloned()
|
|
.unwrap_or_else(|| "Layer".into()),
|
|
)
|
|
.show_ui(ui, |ui| {
|
|
for (index, label) in terrain_layer_labels.iter().enumerate() {
|
|
ui.selectable_value(&mut active_layer, index, label);
|
|
}
|
|
});
|
|
let mut radius = world.resource::<TerrainPaintState>().radius;
|
|
let mut strength = world.resource::<TerrainPaintState>().strength;
|
|
ui.add(
|
|
egui::DragValue::new(&mut radius)
|
|
.range(0.25..=128.0)
|
|
.speed(0.1)
|
|
.prefix("R ")
|
|
.suffix(" m"),
|
|
)
|
|
.on_hover_text("Brush radius");
|
|
ui.add(
|
|
egui::DragValue::new(&mut strength)
|
|
.range(0.01..=1.0)
|
|
.speed(0.02)
|
|
.prefix("S "),
|
|
)
|
|
.on_hover_text("Weight strength per dab");
|
|
let mut state = world.resource_mut::<TerrainPaintState>();
|
|
state.mode = mode;
|
|
state.active_layer = active_layer;
|
|
state.radius = radius;
|
|
state.strength = strength;
|
|
state.clamp_settings();
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
let toolbar_clicked = toolbar_response.response.contains_pointer()
|
|
&& ctx.input(|input| input.pointer.button_clicked(egui::PointerButton::Primary));
|
|
if toolbar_clicked {
|
|
world.resource_mut::<ViewportClick>().0 = None;
|
|
}
|
|
world.resource_mut::<ViewportUiState>().toolbar_rect = Some(toolbar_response.response.rect);
|
|
|
|
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 surface_drag = drag_selection
|
|
.as_ref()
|
|
.is_some_and(|selection| is_surface_asset_selection(world, selection));
|
|
let mut 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));
|
|
let surface_feedback = surface_drag
|
|
.then(|| world.get_resource::<MaterialDropState>())
|
|
.flatten()
|
|
.and_then(MaterialDropState::feedback)
|
|
.cloned();
|
|
if surface_drag && over_viewport {
|
|
if let Some((_, _, action)) = drag_descriptor.as_mut() {
|
|
*action = surface_feedback
|
|
.as_ref()
|
|
.map(|feedback| feedback.action.clone())
|
|
.unwrap_or_else(|| "Finding an assignable surface".into());
|
|
}
|
|
}
|
|
let drop_valid = (surface_drag && over_viewport)
|
|
.then(|| surface_feedback.as_ref().map(|feedback| feedback.valid))
|
|
.flatten();
|
|
let highlight_color = match drop_valid {
|
|
Some(true) => SUCCESS,
|
|
Some(false) => ERROR,
|
|
None => ACCENT_HOVER,
|
|
};
|
|
|
|
let drag_visual_state = if !over_viewport {
|
|
AssetDragVisualState::Outside
|
|
} else {
|
|
match drop_valid {
|
|
Some(true) => AssetDragVisualState::Valid,
|
|
Some(false) => AssetDragVisualState::Invalid,
|
|
None => AssetDragVisualState::Targeted,
|
|
}
|
|
};
|
|
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,
|
|
drag_visual_state,
|
|
);
|
|
}
|
|
|
|
if drag_selection.is_some() && over_viewport {
|
|
ui.painter().rect_stroke(
|
|
viewport_rect.shrink(2.0),
|
|
0.0,
|
|
egui::Stroke::new(2.0, highlight_color),
|
|
egui::StrokeKind::Inside,
|
|
);
|
|
}
|
|
|
|
let pointer_released = ui.input(|input| input.pointer.any_released());
|
|
if !pointer_released {
|
|
return;
|
|
}
|
|
|
|
if surface_drag && over_viewport {
|
|
// The Last-schedule surface-drop session owns exact hit resolution and commit.
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
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,
|
|
..
|
|
} => "Drop onto a primitive or brush face",
|
|
AssetSelection::SubAsset {
|
|
kind: AssetSubAssetKind::Material,
|
|
..
|
|
} => "Drop onto an exact renderer slot",
|
|
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 => "Drop onto a primitive or brush face",
|
|
crate::assets::EditorAssetKind::Material => "Drop onto a renderable surface",
|
|
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()))
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum AssetDragVisualState {
|
|
Outside,
|
|
Targeted,
|
|
Valid,
|
|
Invalid,
|
|
}
|
|
|
|
impl AssetDragVisualState {
|
|
fn over_viewport(self) -> bool {
|
|
self != Self::Outside
|
|
}
|
|
|
|
fn color(self) -> egui::Color32 {
|
|
match self {
|
|
Self::Outside => TEXT_DIM,
|
|
Self::Targeted => ACCENT_HOVER,
|
|
Self::Valid => SUCCESS,
|
|
Self::Invalid => ERROR,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn draw_asset_drag_preview(
|
|
ui: &egui::Ui,
|
|
viewport_rect: egui::Rect,
|
|
pointer: egui::Pos2,
|
|
icon: egui_phosphor_icons::Icon,
|
|
label: &str,
|
|
action: &str,
|
|
visual_state: AssetDragVisualState,
|
|
) {
|
|
let over_viewport = visual_state.over_viewport();
|
|
let highlight_color = visual_state.color();
|
|
if over_viewport {
|
|
let painter = ui.painter();
|
|
painter.circle_filled(pointer, 3.0, highlight_color);
|
|
painter.circle_stroke(pointer, 11.0, egui::Stroke::new(1.5, highlight_color));
|
|
painter.line_segment(
|
|
[
|
|
pointer - egui::vec2(17.0, 0.0),
|
|
pointer - egui::vec2(7.0, 0.0),
|
|
],
|
|
egui::Stroke::new(1.5, highlight_color),
|
|
);
|
|
painter.line_segment(
|
|
[
|
|
pointer + egui::vec2(7.0, 0.0),
|
|
pointer + egui::vec2(17.0, 0.0),
|
|
],
|
|
egui::Stroke::new(1.5, highlight_color),
|
|
);
|
|
painter.line_segment(
|
|
[
|
|
pointer - egui::vec2(0.0, 17.0),
|
|
pointer - egui::vec2(0.0, 7.0),
|
|
],
|
|
egui::Stroke::new(1.5, highlight_color),
|
|
);
|
|
painter.line_segment(
|
|
[
|
|
pointer + egui::vec2(0.0, 7.0),
|
|
pointer + egui::vec2(0.0, 17.0),
|
|
],
|
|
egui::Stroke::new(1.5, highlight_color),
|
|
);
|
|
}
|
|
|
|
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 {
|
|
highlight_color
|
|
} 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 {
|
|
highlight_color
|
|
} 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, highlight_color));
|
|
ui.painter()
|
|
.line_segment([a, a + c], egui::Stroke::new(3.0, highlight_color));
|
|
}
|
|
}
|
|
}
|