Add brush element edit modes
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run

This commit is contained in:
Rbanh 2026-06-06 05:57:50 -04:00
parent fcd34f9079
commit 0bfc062ba7
8 changed files with 576 additions and 1 deletions

View File

@ -112,6 +112,8 @@ VS Code tasks: **target cleanup (dry run)** and **target cleanup (safe apply)**.
| `X` | Toggle world/local gizmo orientation |
| `B` | Enter Draw Brush mode |
| Draw Brush: LMB / `Enter` / `Esc` / `Backspace` | Place floor points / create brush / cancel / remove last point |
| Brush selected: `1` / `2` / `3` / `4` | Vertex / edge / face / clip edit modes |
| Brush edit mode: LMB / `Shift+LMB` / `Esc` | Select element / toggle element selection / return to object mode |
| Viewport toolbar (sun / brush / box icons) | Shading: Lit, Unlit (albedo), Colliders (mesh off) |
| Viewport eye/options | Toggle actor root icon categories, adjust icon/gizmo size, and control colliders, lights, spawns, prefab/model anchors, and runtime player/camera visualizers |
| `Tab` in viewport | Cycle selection through overlapping objects at last click |

View File

@ -29,6 +29,7 @@ pub use scene::scene_io;
pub use scene::scene_schema;
pub use scene::scene_view;
pub use viewport::actor_icons;
pub use viewport::brush_edit;
pub use viewport::brush_tool;
pub use viewport::camera;
pub use viewport::gizmos;
@ -48,6 +49,7 @@ use transform_gizmo_bevy::prelude::TransformGizmoPlugin;
use actor_icons::ActorIconsPlugin;
use asset_db::AssetDbPlugin;
use brush_edit::BrushEditPlugin;
use brush_tool::BrushToolPlugin;
use camera::EditorCameraPlugin;
use extensibility::ExtensibilityPlugin;
@ -91,6 +93,7 @@ impl PluginGroup for EditorPluginGroup {
.add(SceneViewPlugin)
.add(PlaySessionPlugin)
.add(ViewportPlugin)
.add(BrushEditPlugin)
.add(BrushToolPlugin)
.add(EditorCameraPlugin)
.add(ActorIconsPlugin)

View File

@ -15,6 +15,7 @@ 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,
@ -109,6 +110,7 @@ pub fn viewport_tab_ui(
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);
@ -150,6 +152,31 @@ fn scene_view_mode_badge(world: &World, ctx: &egui::Context, scene_rect: egui::R
}
}
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;

View File

@ -0,0 +1,532 @@
//! Brush element edit modes and viewport element picking.
use bevy::prelude::*;
use bevy_egui::{egui, EguiContexts};
use shared::{BrushDesc, BrushFaceDesc, ComponentInstanceId};
use crate::camera::EditorCamera;
use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus};
use crate::scene_io::SceneIo;
use crate::selection::ViewportClick;
use crate::state::scene_tools_active;
use crate::ui::{viewport_keyboard_shortcuts_active, UiState};
use crate::viewport::{scene_view_ray, ViewportDisplayMode};
#[derive(Resource, Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum BrushEditMode {
#[default]
Object,
Vertex,
Edge,
Face,
Clip,
}
impl BrushEditMode {
pub fn label(self) -> &'static str {
match self {
Self::Object => "Object",
Self::Vertex => "Vertex",
Self::Edge => "Edge",
Self::Face => "Face",
Self::Clip => "Clip",
}
}
pub fn is_element_mode(self) -> bool {
self != Self::Object
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum BrushElementKey {
Vertex {
face: ComponentInstanceId,
index: usize,
},
Edge {
face: ComponentInstanceId,
index: usize,
},
Face {
face: ComponentInstanceId,
},
}
#[derive(Resource, Default, Debug, Clone)]
pub struct BrushElementSelection {
pub brush: Option<Entity>,
pub elements: Vec<BrushElementKey>,
}
impl BrushElementSelection {
fn replace(&mut self, brush: Entity, element: BrushElementKey) {
self.brush = Some(brush);
self.elements.clear();
self.elements.push(element);
}
fn toggle(&mut self, brush: Entity, element: BrushElementKey) {
if self.brush != Some(brush) {
self.replace(brush, element);
return;
}
if let Some(index) = self
.elements
.iter()
.position(|existing| existing == &element)
{
self.elements.remove(index);
} else {
self.elements.push(element);
}
}
pub fn clear(&mut self) {
self.brush = None;
self.elements.clear();
}
}
pub struct BrushEditPlugin;
impl Plugin for BrushEditPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<BrushEditMode>()
.init_resource::<BrushElementSelection>()
.add_systems(
Update,
(
brush_edit_hotkeys,
brush_element_pick,
draw_brush_edit_overlays,
)
.chain()
.run_if(scene_tools_active),
);
}
}
#[allow(clippy::too_many_arguments)]
fn brush_edit_hotkeys(
mut mode: ResMut<BrushEditMode>,
mut element_selection: ResMut<BrushElementSelection>,
mut active_operator: ResMut<ActiveOperator>,
mut scene_io: ResMut<SceneIo>,
keys: Res<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>,
mut contexts: EguiContexts,
ui_state: Res<UiState>,
display: Res<ViewportDisplayMode>,
brushes: Query<(), With<BrushDesc>>,
) -> Result {
if display.clean_game_view {
return Ok(());
}
let ctx = contexts.ctx_mut()?;
if !viewport_keyboard_shortcuts_active(&ui_state, ctx, &buttons) || ctx.wants_keyboard_input() {
return Ok(());
}
if keys.just_pressed(KeyCode::Escape) && mode.is_element_mode() {
*mode = BrushEditMode::Object;
element_selection.clear();
set_brush_edit_status(&mut active_operator, OperatorPhase::Canceled, "Object mode");
scene_io.status = "Brush edit: Object mode".to_string();
return Ok(());
}
let requested = if keys.just_pressed(KeyCode::Digit1) {
Some(BrushEditMode::Vertex)
} else if keys.just_pressed(KeyCode::Digit2) {
Some(BrushEditMode::Edge)
} else if keys.just_pressed(KeyCode::Digit3) {
Some(BrushEditMode::Face)
} else if keys.just_pressed(KeyCode::Digit4) {
Some(BrushEditMode::Clip)
} else {
None
};
let Some(requested) = requested else {
return Ok(());
};
let Some(selected_brush) = selected_brush_entity(&ui_state, &brushes) else {
scene_io.status = "Select a brush before entering brush edit mode".to_string();
set_brush_edit_status(
&mut active_operator,
OperatorPhase::Blocked,
"Select a brush before editing elements",
);
return Ok(());
};
*mode = requested;
element_selection.brush = Some(selected_brush);
element_selection.elements.clear();
scene_io.status = format!("Brush edit: {} mode", mode.label());
set_brush_edit_status(
&mut active_operator,
OperatorPhase::Preview,
format!("{} mode", mode.label()),
);
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn brush_element_pick(
mode: Res<BrushEditMode>,
mut viewport_click: ResMut<ViewportClick>,
mut element_selection: ResMut<BrushElementSelection>,
keys: Res<ButtonInput<KeyCode>>,
ui_state: Res<UiState>,
cameras: Query<(&Camera, &GlobalTransform), With<EditorCamera>>,
brushes: Query<(Entity, &BrushDesc, &GlobalTransform)>,
) {
if !mode.is_element_mode() {
return;
}
let Some(pointer_pos) = viewport_click.0.take() else {
return;
};
let Some(ray) = viewport_ray(&cameras, pointer_pos, ui_state.viewport_rect) else {
return;
};
let brush_filter = element_selection.brush;
let mut best: Option<(Entity, BrushElementKey, f32)> = None;
for (entity, brush, transform) in &brushes {
if brush_filter.is_some_and(|selected| selected != entity) {
continue;
}
if let Some((element, distance)) = pick_element(*mode, brush, transform, ray) {
if best
.as_ref()
.is_none_or(|(_, _, best_distance)| distance < *best_distance)
{
best = Some((entity, element, distance));
}
}
}
let Some((brush, element, _)) = best else {
if !keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]) {
element_selection.clear();
}
return;
};
if keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]) {
element_selection.toggle(brush, element);
} else {
element_selection.replace(brush, element);
}
}
fn draw_brush_edit_overlays(
mode: Res<BrushEditMode>,
selection: Res<BrushElementSelection>,
ui_state: Res<UiState>,
brushes: Query<(Entity, &BrushDesc, &GlobalTransform)>,
mut gizmos: Gizmos,
) {
if !mode.is_element_mode() {
return;
}
let selected_brushes: Vec<Entity> = ui_state.selected_entities.iter().collect();
for (entity, brush, transform) in &brushes {
if !selected_brushes.contains(&entity) {
continue;
}
let active = selection.brush == Some(entity);
draw_brush_elements(
*mode,
brush,
transform,
&selection.elements,
active,
&mut gizmos,
);
}
}
fn draw_brush_elements(
mode: BrushEditMode,
brush: &BrushDesc,
transform: &GlobalTransform,
selected: &[BrushElementKey],
active: bool,
gizmos: &mut Gizmos,
) {
let base_color = match mode {
BrushEditMode::Vertex => Color::srgb(0.2, 0.72, 1.0),
BrushEditMode::Edge => Color::srgb(1.0, 0.68, 0.24),
BrushEditMode::Face => Color::srgb(0.24, 1.0, 0.56),
BrushEditMode::Clip => Color::srgb(0.92, 0.36, 1.0),
BrushEditMode::Object => Color::WHITE,
};
let selected_color = Color::srgb(1.0, 0.95, 0.2);
for face in &brush.faces {
let vertices = world_vertices(face, transform);
if vertices.is_empty() {
continue;
}
let face_center = average_point(&vertices);
let normal = transform
.affine()
.transform_vector3(face.plane.normal)
.normalize_or_zero();
if matches!(mode, BrushEditMode::Face | BrushEditMode::Clip) {
let key = BrushElementKey::Face {
face: face.id.clone(),
};
let color = if active && selected.contains(&key) {
selected_color
} else {
base_color
};
gizmos.sphere(face_center, 0.08, color);
gizmos.line(face_center, face_center + normal * 0.55, color);
}
for index in 0..vertices.len() {
let next = (index + 1) % vertices.len();
if matches!(mode, BrushEditMode::Vertex) {
let key = BrushElementKey::Vertex {
face: face.id.clone(),
index,
};
let color = if active && selected.contains(&key) {
selected_color
} else {
base_color
};
gizmos.sphere(vertices[index], 0.06, color);
}
if matches!(mode, BrushEditMode::Edge | BrushEditMode::Clip) {
let key = BrushElementKey::Edge {
face: face.id.clone(),
index,
};
let color = if active && selected.contains(&key) {
selected_color
} else {
base_color
};
gizmos.line(vertices[index], vertices[next], color);
}
}
}
}
fn selected_brush_entity(
ui_state: &UiState,
brushes: &Query<(), With<BrushDesc>>,
) -> Option<Entity> {
ui_state
.selected_entities
.iter()
.find(|entity| brushes.contains(*entity))
}
fn viewport_ray(
cameras: &Query<(&Camera, &GlobalTransform), With<EditorCamera>>,
pointer_pos: egui::Pos2,
scene_rect: egui::Rect,
) -> Option<Ray3d> {
let (camera, transform) = cameras
.iter()
.find(|(camera, _)| camera.is_active)
.or_else(|| cameras.iter().next())?;
scene_view_ray(camera, transform, pointer_pos, scene_rect)
}
fn pick_element(
mode: BrushEditMode,
brush: &BrushDesc,
transform: &GlobalTransform,
ray: Ray3d,
) -> Option<(BrushElementKey, f32)> {
match mode {
BrushEditMode::Vertex => pick_vertex(brush, transform, ray),
BrushEditMode::Edge => pick_edge(brush, transform, ray),
BrushEditMode::Face | BrushEditMode::Clip => pick_face(brush, transform, ray),
BrushEditMode::Object => None,
}
}
fn pick_vertex(
brush: &BrushDesc,
transform: &GlobalTransform,
ray: Ray3d,
) -> Option<(BrushElementKey, f32)> {
let mut best = None;
for face in &brush.faces {
for (index, vertex) in world_vertices(face, transform).iter().enumerate() {
let distance = distance_ray_point(ray, *vertex);
if distance < 0.25 && best.as_ref().is_none_or(|(_, best)| distance < *best) {
best = Some((
BrushElementKey::Vertex {
face: face.id.clone(),
index,
},
distance,
));
}
}
}
best
}
fn pick_edge(
brush: &BrushDesc,
transform: &GlobalTransform,
ray: Ray3d,
) -> Option<(BrushElementKey, f32)> {
let mut best = None;
for face in &brush.faces {
let vertices = world_vertices(face, transform);
for index in 0..vertices.len() {
let next = (index + 1) % vertices.len();
let distance = distance_ray_segment(ray, vertices[index], vertices[next]);
if distance < 0.2 && best.as_ref().is_none_or(|(_, best)| distance < *best) {
best = Some((
BrushElementKey::Edge {
face: face.id.clone(),
index,
},
distance,
));
}
}
}
best
}
fn pick_face(
brush: &BrushDesc,
transform: &GlobalTransform,
ray: Ray3d,
) -> Option<(BrushElementKey, f32)> {
let mut best = None;
for face in &brush.faces {
let vertices = world_vertices(face, transform);
if vertices.len() < 3 {
continue;
}
let normal = transform
.affine()
.transform_vector3(face.plane.normal)
.normalize_or_zero();
let denom = normal.dot(ray.direction.as_vec3());
if denom.abs() < 0.0001 {
continue;
}
let distance = normal.dot(vertices[0] - ray.origin) / denom;
if distance < 0.0 {
continue;
}
let hit = ray.origin + ray.direction.as_vec3() * distance;
if point_in_convex_polygon(hit, &vertices, normal)
&& best.as_ref().is_none_or(|(_, best)| distance < *best)
{
best = Some((
BrushElementKey::Face {
face: face.id.clone(),
},
distance,
));
}
}
best
}
fn world_vertices(face: &BrushFaceDesc, transform: &GlobalTransform) -> Vec<Vec3> {
face.vertices
.iter()
.map(|vertex| transform.transform_point(*vertex))
.collect()
}
fn average_point(points: &[Vec3]) -> Vec3 {
points.iter().copied().sum::<Vec3>() / points.len() as f32
}
fn point_in_convex_polygon(point: Vec3, vertices: &[Vec3], normal: Vec3) -> bool {
let mut expected_sign = 0.0f32;
for index in 0..vertices.len() {
let next = (index + 1) % vertices.len();
let edge = vertices[next] - vertices[index];
let to_point = point - vertices[index];
let side = edge.cross(to_point).dot(normal);
if side.abs() <= 0.0001 {
continue;
}
if expected_sign == 0.0 {
expected_sign = side.signum();
} else if side.signum() != expected_sign {
return false;
}
}
true
}
fn distance_ray_point(ray: Ray3d, point: Vec3) -> f32 {
let to_point = point - ray.origin;
let t = to_point.dot(ray.direction.as_vec3()).max(0.0);
(ray.origin + ray.direction.as_vec3() * t - point).length()
}
fn distance_ray_segment(ray: Ray3d, a: Vec3, b: Vec3) -> f32 {
let ray_dir = ray.direction.as_vec3();
let segment = b - a;
let w0 = ray.origin - a;
let a_dot = ray_dir.dot(ray_dir);
let b_dot = ray_dir.dot(segment);
let c_dot = segment.dot(segment);
let d_dot = ray_dir.dot(w0);
let e_dot = segment.dot(w0);
let denom = a_dot * c_dot - b_dot * b_dot;
let ray_t = if denom.abs() > 0.0001 {
((b_dot * e_dot - c_dot * d_dot) / denom).max(0.0)
} else {
0.0
};
let segment_t = if c_dot > 0.0001 {
((a_dot * e_dot - b_dot * d_dot) / denom.max(0.0001)).clamp(0.0, 1.0)
} else {
0.0
};
let ray_point = ray.origin + ray_dir * ray_t;
let segment_point = a + segment * segment_t;
ray_point.distance(segment_point)
}
fn set_brush_edit_status(
active_operator: &mut ActiveOperator,
phase: OperatorPhase,
hint: impl Into<String>,
) {
active_operator.status = Some(OperatorStatus {
id: "brush.edit_mode".to_string(),
label: "Brush Edit".to_string(),
phase,
hint: hint.into(),
warnings: Vec::new(),
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn point_inside_convex_polygon_is_detected() {
let vertices = vec![
Vec3::new(-1.0, 0.0, -1.0),
Vec3::new(1.0, 0.0, -1.0),
Vec3::new(1.0, 0.0, 1.0),
Vec3::new(-1.0, 0.0, 1.0),
];
assert!(point_in_convex_polygon(Vec3::ZERO, &vertices, Vec3::Y));
assert!(!point_in_convex_polygon(
Vec3::new(2.0, 0.0, 0.0),
&vertices,
Vec3::Y
));
}
}

View File

@ -1,6 +1,7 @@
//! Viewport chrome, editor camera, selection, gizmos, and render views.
pub mod actor_icons;
pub mod brush_edit;
pub mod brush_tool;
pub mod camera;
pub mod gizmos;
@ -13,6 +14,7 @@ pub mod viewport_mode;
pub mod visualizers;
pub use actor_icons::ActorIconsPlugin;
pub use brush_edit::{BrushEditMode, BrushEditPlugin, BrushElementSelection};
pub use brush_tool::{BrushToolPlugin, BrushToolState};
pub use panel::*;
pub use viewport_mode::EditorViewportMode;

View File

@ -14,6 +14,7 @@ use crate::ui::hierarchy_ops::is_entity_locked;
use crate::ui::hierarchy_state::HierarchyPanelState;
use crate::ui::UiState;
use crate::viewport::actor_icons::ActorIconProxy;
use crate::viewport::brush_edit::BrushEditMode;
use crate::viewport::brush_tool::BrushToolState;
use crate::viewport::ViewportDisplayMode;
use crate::visualizers::EditorVisualizerProxy;
@ -103,6 +104,7 @@ fn handle_pick_events(
keys: Res<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>,
display: Res<ViewportDisplayMode>,
brush_mode: Res<BrushEditMode>,
brush_tool: Res<BrushToolState>,
gizmo_targets: Query<&GizmoTarget>,
hierarchy: Option<Res<HierarchyPanelState>>,
@ -124,7 +126,7 @@ fn handle_pick_events(
return Ok(());
}
if brush_tool.active {
if brush_tool.active || brush_mode.is_element_mode() {
return Ok(());
}

View File

@ -45,6 +45,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
- **Static mesh renderer** — model drag/drop defaults to `ActorKind::StaticMesh + StaticMeshRenderer` using normalized artifacts under `assets/meshes/generated/`. Renderer slots store imported `EditorAssetRef` mesh/material references, not source FBX/glTF paths. `SceneInstance` placement keeps the legacy `ImportedModel + ModelRef` path for full scene playback.
- **Brush authoring**`ActorKind::Brush + BrushDesc` stores persisted convex blockout faces and hydrates active brushes into generated mesh children. See [brushes.md](brushes.md).
- **Draw Brush**`B`, toolbar pencil, or command `brush.draw` enters a floor-polygon draw mode. LMB places snapped points, Backspace removes the last point, Enter creates an additive prism brush through history, and Esc/right-click cancels.
- **Brush edit modes** — with a brush selected, `1`/`2`/`3`/`4` enter vertex, edge, face, and clip element modes. Element modes show brush handles in the viewport, own LMB picking, support Shift multi-select, show a mode badge, and Esc returns to object mode. Geometry mutation is staged for the follow-up edit operation slice.
- **Collider split** — imported mesh collision lives in a separate `ColliderDesc::StaticMesh` plus optional `RigidBodyDesc`; renderer slots own only render visibility, shadows, mesh, and material references.
- **Material assets**`MaterialAsset` RON (`label` + `MaterialDesc`) is shader-schema aware. Actor material edits live on the actor `MaterialDesc`; static mesh slot material refs are source/default selectors, and content-browser asset inspectors are the path for editing shared material assets.
- **Prefab overrides**`PrefabInstance.overrides_ron` stores transform/material/child-visibility overrides; inspector collapsible groups with **Apply** / **Revert** (replaces transform-only revert v1).

View File

@ -11,6 +11,12 @@ Brushes are persisted blockout geometry stored as `ActorKind::Brush + BrushDesc`
- The Actor Inspector Add Component shelf can add a **Brush** component. The Brush card exposes kind, face count, shadow flags, and cube reset.
- Draw Brush mode (`B`, toolbar pencil, or command `brush.draw`) creates additive prism brushes from snapped floor points. LMB places points, Backspace removes the last point, Enter commits through undo history, and Esc/right-click cancels without scene mutation.
## Element Modes
With a brush actor selected, `1`/`2`/`3`/`4` enter vertex, edge, face, and clip modes. Element modes show viewport overlays for the selected brush, route LMB to element picking, support Shift+LMB multi-select, and display a `Brush: <mode>` badge. `Esc` returns to object mode.
The current slice selects elements and visualizes handles; mutating vertices/faces and committing drag edits through history is the next implementation layer.
## Current Limits
- Only convex authored faces are supported by the mesh builder.