From 0bfc062ba78e5cccf7340c86825db5ca4041434d Mon Sep 17 00:00:00 2001 From: Rbanh Date: Sat, 6 Jun 2026 05:57:50 -0400 Subject: [PATCH] Add brush element edit modes --- README.md | 2 + crates/editor/src/lib.rs | 3 + crates/editor/src/ui/viewport_chrome.rs | 27 ++ crates/editor/src/viewport/brush_edit.rs | 532 +++++++++++++++++++++++ crates/editor/src/viewport/mod.rs | 2 + crates/editor/src/viewport/selection.rs | 4 +- docs/editor/README.md | 1 + docs/editor/brushes.md | 6 + 8 files changed, 576 insertions(+), 1 deletion(-) create mode 100644 crates/editor/src/viewport/brush_edit.rs diff --git a/README.md b/README.md index 0bf9fb6..ba6dd1e 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/crates/editor/src/lib.rs b/crates/editor/src/lib.rs index ab206a5..b9fda09 100644 --- a/crates/editor/src/lib.rs +++ b/crates/editor/src/lib.rs @@ -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) diff --git a/crates/editor/src/ui/viewport_chrome.rs b/crates/editor/src/ui/viewport_chrome.rs index ecdc3cd..759eb53 100644 --- a/crates/editor/src/ui/viewport_chrome.rs +++ b/crates/editor/src/ui/viewport_chrome.rs @@ -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::().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::() else { return; diff --git a/crates/editor/src/viewport/brush_edit.rs b/crates/editor/src/viewport/brush_edit.rs new file mode 100644 index 0000000..7f378e0 --- /dev/null +++ b/crates/editor/src/viewport/brush_edit.rs @@ -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, + pub elements: Vec, +} + +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::() + .init_resource::() + .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, + mut element_selection: ResMut, + mut active_operator: ResMut, + mut scene_io: ResMut, + keys: Res>, + buttons: Res>, + mut contexts: EguiContexts, + ui_state: Res, + display: Res, + brushes: Query<(), With>, +) -> 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, + mut viewport_click: ResMut, + mut element_selection: ResMut, + keys: Res>, + ui_state: Res, + cameras: Query<(&Camera, &GlobalTransform), With>, + 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, + selection: Res, + ui_state: Res, + brushes: Query<(Entity, &BrushDesc, &GlobalTransform)>, + mut gizmos: Gizmos, +) { + if !mode.is_element_mode() { + return; + } + let selected_brushes: Vec = 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>, +) -> Option { + ui_state + .selected_entities + .iter() + .find(|entity| brushes.contains(*entity)) +} + +fn viewport_ray( + cameras: &Query<(&Camera, &GlobalTransform), With>, + pointer_pos: egui::Pos2, + scene_rect: egui::Rect, +) -> Option { + 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 { + face.vertices + .iter() + .map(|vertex| transform.transform_point(*vertex)) + .collect() +} + +fn average_point(points: &[Vec3]) -> Vec3 { + points.iter().copied().sum::() / 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, +) { + 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 + )); + } +} diff --git a/crates/editor/src/viewport/mod.rs b/crates/editor/src/viewport/mod.rs index 40b6350..017e7c8 100644 --- a/crates/editor/src/viewport/mod.rs +++ b/crates/editor/src/viewport/mod.rs @@ -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; diff --git a/crates/editor/src/viewport/selection.rs b/crates/editor/src/viewport/selection.rs index e8ab08b..c69fc29 100644 --- a/crates/editor/src/viewport/selection.rs +++ b/crates/editor/src/viewport/selection.rs @@ -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>, buttons: Res>, display: Res, + brush_mode: Res, brush_tool: Res, gizmo_targets: Query<&GizmoTarget>, hierarchy: Option>, @@ -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(()); } diff --git a/docs/editor/README.md b/docs/editor/README.md index 4d5ee64..353ef1e 100644 --- a/docs/editor/README.md +++ b/docs/editor/README.md @@ -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). diff --git a/docs/editor/brushes.md b/docs/editor/brushes.md index ad17354..9bd89c8 100644 --- a/docs/editor/brushes.md +++ b/docs/editor/brushes.md @@ -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: ` 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.