From fcd34f9079e8f6b8bcfc2d8a42866c0e790dc8f2 Mon Sep 17 00:00:00 2001 From: Rbanh Date: Sat, 6 Jun 2026 05:52:52 -0400 Subject: [PATCH] Add draw brush tool --- README.md | 4 + crates/editor/src/ext/extensibility.rs | 23 ++ crates/editor/src/lib.rs | 3 + crates/editor/src/ui/toolbar.rs | 4 + crates/editor/src/viewport/brush_tool.rs | 350 +++++++++++++++++++++++ crates/editor/src/viewport/mod.rs | 2 + crates/editor/src/viewport/selection.rs | 6 + crates/shared/src/brush_math.rs | 131 +++++++++ crates/shared/src/components.rs | 43 +++ crates/shared/src/lib.rs | 1 + docs/editor/README.md | 3 +- docs/editor/brushes.md | 1 + 12 files changed, 570 insertions(+), 1 deletion(-) create mode 100644 crates/editor/src/viewport/brush_tool.rs create mode 100644 crates/shared/src/brush_math.rs diff --git a/README.md b/README.md index baad12d..0bf9fb6 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,8 @@ VS Code tasks: **target cleanup (dry run)** and **target cleanup (safe apply)**. | `F2` in Hierarchy | Rename selection | | `W` / `E` / `R` | Translate / rotate / scale gizmo | | `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 | | 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 | @@ -183,6 +185,8 @@ Viewport shortcut keys require the pointer to be in the viewport and are suspend subasset places that part through the same static mesh renderer path. - Brush actors are persisted as `ActorKind::Brush + BrushDesc`; the MVP hydrates additive convex cube brushes into generated preview meshes while face/edge/CSG editing remains roadmap work. +- Draw Brush mode (`B` or toolbar pencil) places snapped floor points and commits an additive + prism brush with `Enter`; `Esc` or right-click cancels without changing the scene. - Runtime-only handles/colliders are not serialized directly, keeping scenes stable and portable. - Editor-only cameras and helper roots are filtered from selection, hierarchy, and scene save. - **PIE restores player sim only** (transform, velocity, jump state) when you stop Play; authored diff --git a/crates/editor/src/ext/extensibility.rs b/crates/editor/src/ext/extensibility.rs index b64db20..1cc46d8 100644 --- a/crates/editor/src/ext/extensibility.rs +++ b/crates/editor/src/ext/extensibility.rs @@ -12,6 +12,7 @@ use crate::ui::helpers::{ use crate::ui::selection_ops::{focus_editor_camera_on_selection, reset_selected_transforms}; use crate::ui::theme::{apply_editor_theme, PANEL_BG, TEXT, TEXT_DIM}; use crate::ui::UiState; +use crate::viewport::brush_tool::start_draw_brush_tool; /// Named editor command invokable from the palette and BRP. pub trait EditorCommand: Send + Sync { @@ -180,6 +181,7 @@ fn register_builtin_commands(mut registry: ResMut) { registry.register(Box::new(CreatePostProcessVolumeCommand)); registry.register(Box::new(FocusActiveVolumesCommand)); registry.register(Box::new(SelectVolumesAtCameraCommand)); + registry.register(Box::new(DrawBrushCommand)); } fn run_palette_commands(world: &mut World) { @@ -331,6 +333,27 @@ impl EditorCommand for ResetSelectionTransformCommand { } } +struct DrawBrushCommand; + +impl EditorCommand for DrawBrushCommand { + fn name(&self) -> &str { + "brush.draw" + } + + fn label(&self) -> &str { + "Draw Brush" + } + + fn disabled_reason(&self, world: &World) -> Option { + (*world.resource::>().get() != EditorMode::Editing) + .then(|| "Enter Edit mode before drawing brushes".to_string()) + } + + fn execute(&self, world: &mut World) { + start_draw_brush_tool(world); + } +} + struct CreatePostProcessVolumeCommand; impl EditorCommand for CreatePostProcessVolumeCommand { diff --git a/crates/editor/src/lib.rs b/crates/editor/src/lib.rs index 53dacf4..ab206a5 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_tool; pub use viewport::camera; pub use viewport::gizmos; pub use viewport::render_view; @@ -47,6 +48,7 @@ use transform_gizmo_bevy::prelude::TransformGizmoPlugin; use actor_icons::ActorIconsPlugin; use asset_db::AssetDbPlugin; +use brush_tool::BrushToolPlugin; use camera::EditorCameraPlugin; use extensibility::ExtensibilityPlugin; use gizmos::EditorGizmoPlugin; @@ -89,6 +91,7 @@ impl PluginGroup for EditorPluginGroup { .add(SceneViewPlugin) .add(PlaySessionPlugin) .add(ViewportPlugin) + .add(BrushToolPlugin) .add(EditorCameraPlugin) .add(ActorIconsPlugin) .add(EditorSelectionPlugin) diff --git a/crates/editor/src/ui/toolbar.rs b/crates/editor/src/ui/toolbar.rs index 4c2fafc..c849632 100644 --- a/crates/editor/src/ui/toolbar.rs +++ b/crates/editor/src/ui/toolbar.rs @@ -8,6 +8,7 @@ use crate::history::spawn_with_history; use crate::scene_io::{SceneIo, SceneIoRequest}; use crate::state::{EditorMode, PlayPaused, PlayPossession}; use crate::ui::UiState; +use crate::viewport::brush_tool::start_draw_brush_tool; use crate::viewport::{editor_spawn_ground_position, snap_translation, ViewportSettings}; use shared::PrimitiveShape; @@ -87,6 +88,9 @@ fn left_toolbar(world: &mut World, ui: &mut egui::Ui) { let pos = camera_ground_spawn(world); spawn_with_history(world, light_snapshot("Point Light", pos)); } + if icon_button(ui, icons::PENCIL_SIMPLE_LINE, "Draw brush (B)").clicked() { + start_draw_brush_tool(world); + } toolbar_separator(ui); diff --git a/crates/editor/src/viewport/brush_tool.rs b/crates/editor/src/viewport/brush_tool.rs new file mode 100644 index 0000000..41177f0 --- /dev/null +++ b/crates/editor/src/viewport/brush_tool.rs @@ -0,0 +1,350 @@ +//! Modal floor-polygon brush drawing tool. + +use bevy::prelude::*; +use bevy_egui::{egui, EguiContexts}; +use shared::{ + brush_math::validate_floor_polygon, ActorKind, BrushDesc, EditorVisibility, MaterialDesc, +}; + +use crate::camera::EditorCamera; +use crate::history::{spawn_with_history, EditorEntitySnapshot}; +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, snap_translation, ViewportDisplayMode, ViewportSettings}; + +const DEFAULT_BRUSH_HEIGHT: f32 = 2.5; +const MIN_COMMIT_VERTICES: usize = 3; + +#[derive(Resource, Debug, Clone)] +pub struct BrushToolState { + pub active: bool, + pub vertices: Vec, + pub height: f32, +} + +impl Default for BrushToolState { + fn default() -> Self { + Self { + active: false, + vertices: Vec::new(), + height: DEFAULT_BRUSH_HEIGHT, + } + } +} + +impl BrushToolState { + pub fn start(&mut self) { + self.active = true; + self.vertices.clear(); + self.height = DEFAULT_BRUSH_HEIGHT; + } + + pub fn cancel(&mut self) { + self.active = false; + self.vertices.clear(); + } +} + +pub struct BrushToolPlugin; + +impl Plugin for BrushToolPlugin { + fn build(&self, app: &mut App) { + app.init_resource::().add_systems( + Update, + (brush_tool_input, draw_brush_tool_preview).run_if(scene_tools_active), + ); + } +} + +pub fn start_draw_brush_tool(world: &mut World) { + world.resource_mut::().start(); + world.resource_mut::().status = + "Draw Brush: click floor points, Enter creates brush".to_string(); + if let Some(mut active_operator) = world.get_resource_mut::() { + set_brush_tool_status( + &mut active_operator, + OperatorPhase::Preview, + "Click floor points, Enter creates brush, Esc cancels", + ); + } + if let Some(mut viewport_click) = world.get_resource_mut::() { + viewport_click.0 = None; + } +} + +#[allow(clippy::too_many_arguments)] +fn brush_tool_input( + mut commands: Commands, + mut tool: ResMut, + mut viewport_click: ResMut, + mut active_operator: ResMut, + mut scene_io: ResMut, + keys: Res>, + buttons: Res>, + mut contexts: EguiContexts, + ui_state: Res, + display: Res, + settings: Res, + cameras: Query<(&Camera, &GlobalTransform), With>, +) -> Result { + if display.clean_game_view { + if tool.active { + tool.cancel(); + set_brush_tool_status(&mut active_operator, OperatorPhase::Canceled, "Canceled"); + } + return Ok(()); + } + + let ctx = contexts.ctx_mut()?; + let egui_keyboard_busy = ctx.wants_keyboard_input(); + let keyboard_available = + viewport_keyboard_shortcuts_active(&ui_state, ctx, &buttons) && !egui_keyboard_busy; + + if keyboard_available && keys.just_pressed(KeyCode::KeyB) { + tool.start(); + scene_io.status = "Draw Brush: click floor points, Enter creates brush".to_string(); + set_brush_tool_status( + &mut active_operator, + OperatorPhase::Preview, + "Click floor points, Enter creates brush, Esc cancels", + ); + viewport_click.0 = None; + return Ok(()); + } + + if !tool.active { + return Ok(()); + } + + if (!egui_keyboard_busy && keys.just_pressed(KeyCode::Escape)) + || buttons.just_pressed(MouseButton::Right) + { + tool.cancel(); + viewport_click.0 = None; + scene_io.status = "Draw Brush cancelled".to_string(); + set_brush_tool_status(&mut active_operator, OperatorPhase::Canceled, "Canceled"); + return Ok(()); + } + + if !egui_keyboard_busy && keys.just_pressed(KeyCode::Backspace) { + tool.vertices.pop(); + set_brush_tool_status( + &mut active_operator, + OperatorPhase::Preview, + brush_tool_hint(tool.vertices.len()), + ); + viewport_click.0 = None; + return Ok(()); + } + + if let Some(pointer_pos) = viewport_click.0.take() { + if let Some(mut point) = + pointer_floor_position(&cameras, pointer_pos, ui_state.viewport_rect) + { + point = snap_translation(point, &settings); + tool.vertices.push(point); + scene_io.status = format!("Draw Brush: {} point(s)", tool.vertices.len()); + set_brush_tool_status( + &mut active_operator, + OperatorPhase::Preview, + brush_tool_hint(tool.vertices.len()), + ); + } + } + + if !egui_keyboard_busy && keys.just_pressed(KeyCode::Enter) { + match brush_snapshot_from_points(&tool.vertices, tool.height) { + Ok(snapshot) => { + commands.queue(move |world: &mut World| { + spawn_with_history(world, snapshot); + }); + tool.cancel(); + scene_io.status = "Brush created".to_string(); + set_brush_tool_status( + &mut active_operator, + OperatorPhase::Committed, + "Committed brush.draw", + ); + } + Err(error) => { + scene_io.status = format!("Draw Brush failed: {error}"); + set_brush_tool_status(&mut active_operator, OperatorPhase::Blocked, error); + } + } + } + + Ok(()) +} + +fn draw_brush_tool_preview( + tool: Res, + ui_state: Res, + cameras: Query<(&Camera, &GlobalTransform), With>, + mut gizmos: Gizmos, +) { + if !tool.active { + return; + } + let mut points = tool.vertices.clone(); + if let Some(pointer_pos) = ui_state.viewport_pointer_pos { + if let Some(point) = pointer_floor_position(&cameras, pointer_pos, ui_state.viewport_rect) { + points.push(point); + } + } + if points.is_empty() { + return; + } + let color = Color::srgb(0.2, 0.72, 1.0); + for point in &points { + gizmos.sphere(*point + Vec3::Y * 0.02, 0.08, color); + gizmos.line(*point, *point + Vec3::Y * tool.height, color); + } + for window in points.windows(2) { + gizmos.line(window[0], window[1], color); + gizmos.line( + window[0] + Vec3::Y * tool.height, + window[1] + Vec3::Y * tool.height, + color, + ); + } + if points.len() >= MIN_COMMIT_VERTICES { + let first = points[0]; + let last = *points.last().expect("points checked non-empty"); + gizmos.line(last, first, color); + gizmos.line( + last + Vec3::Y * tool.height, + first + Vec3::Y * tool.height, + color, + ); + } +} + +fn pointer_floor_position( + 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())?; + let ray = scene_view_ray(camera, transform, pointer_pos, scene_rect)?; + let direction = ray.direction.as_vec3(); + if direction.y.abs() < 0.0001 { + return Some(ray.origin); + } + let t = -ray.origin.y / direction.y; + Some(ray.origin + direction * t.max(0.0)) +} + +fn brush_snapshot_from_points( + points: &[Vec3], + height: f32, +) -> Result { + validate_floor_polygon(points).map_err(|error| format!("{error:?}"))?; + let center = polygon_center(points); + let local_points: Vec = points.iter().map(|point| *point - center).collect(); + let brush = BrushDesc::extruded_prism(&local_points, height) + .ok_or_else(|| "invalid brush prism".to_string())?; + Ok(EditorEntitySnapshot { + actor_id: None, + actor_kind: ActorKind::Brush, + actor_name: None, + name: Some("Brush".to_string()), + transform: Transform::from_translation(center), + primitive: None, + brush: Some(brush), + static_mesh_renderer: None, + material: Some(MaterialDesc::default()), + material_override: None, + rigid_body: None, + collider: None, + physics: None, + light: None, + player_spawn: false, + model: None, + prefab: None, + prefab_instance: None, + weapon_spawn: None, + trigger_volume: None, + post_process_volume: None, + team_spawn: None, + objective: None, + hierarchy_sibling_index: 0, + editor_visibility: EditorVisibility::default(), + children: Vec::new(), + }) +} + +fn polygon_center(points: &[Vec3]) -> Vec3 { + let sum = points + .iter() + .copied() + .fold(Vec3::ZERO, |sum, point| sum + point); + sum / points.len() as f32 +} + +fn brush_tool_hint(vertex_count: usize) -> String { + match vertex_count { + 0 => "Click first floor point".to_string(), + 1 => "Click second floor point".to_string(), + 2 => "Click third floor point".to_string(), + count => format!("{count} points, Enter creates brush"), + } +} + +fn set_brush_tool_status( + active_operator: &mut ActiveOperator, + phase: OperatorPhase, + hint: impl Into, +) { + active_operator.status = Some(OperatorStatus { + id: "brush.draw".to_string(), + label: "Draw Brush".to_string(), + phase, + hint: hint.into(), + warnings: Vec::new(), + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn brush_snapshot_centers_polygon_points() { + let snapshot = brush_snapshot_from_points( + &[ + Vec3::new(0.0, 0.0, 0.0), + Vec3::new(2.0, 0.0, 0.0), + Vec3::new(2.0, 0.0, 2.0), + Vec3::new(0.0, 0.0, 2.0), + ], + 3.0, + ) + .expect("valid brush"); + + assert_eq!(snapshot.actor_kind, ActorKind::Brush); + assert_eq!(snapshot.transform.translation, Vec3::new(1.0, 0.0, 1.0)); + assert!(snapshot.brush.is_some()); + } + + #[test] + fn brush_snapshot_rejects_non_convex_polygon() { + let result = brush_snapshot_from_points( + &[ + Vec3::new(-1.0, 0.0, -1.0), + Vec3::new(1.0, 0.0, -1.0), + Vec3::new(0.0, 0.0, 0.0), + Vec3::new(1.0, 0.0, 1.0), + Vec3::new(-1.0, 0.0, 1.0), + ], + 2.0, + ); + + assert!(result.is_err()); + } +} diff --git a/crates/editor/src/viewport/mod.rs b/crates/editor/src/viewport/mod.rs index d0327e5..40b6350 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_tool; pub mod camera; pub mod gizmos; mod panel; @@ -12,5 +13,6 @@ pub mod viewport_mode; pub mod visualizers; pub use actor_icons::ActorIconsPlugin; +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 d7558b4..e8ab08b 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_tool::BrushToolState; use crate::viewport::ViewportDisplayMode; use crate::visualizers::EditorVisualizerProxy; use bevy_egui::egui; @@ -102,6 +103,7 @@ fn handle_pick_events( keys: Res>, buttons: Res>, display: Res, + brush_tool: Res, gizmo_targets: Query<&GizmoTarget>, hierarchy: Option>, ) -> Result { @@ -122,6 +124,10 @@ fn handle_pick_events( return Ok(()); } + if brush_tool.active { + return Ok(()); + } + if gizmo_targets .iter() .any(|g| GizmoTarget::is_focused(g) || GizmoTarget::is_active(g)) diff --git a/crates/shared/src/brush_math.rs b/crates/shared/src/brush_math.rs new file mode 100644 index 0000000..754e99f --- /dev/null +++ b/crates/shared/src/brush_math.rs @@ -0,0 +1,131 @@ +//! Brush geometry validation helpers shared by editor tools and hydration. + +use bevy::prelude::*; + +const EPSILON: f32 = 0.0001; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BrushPolygonError { + TooFewVertices, + NonFiniteVertex, + DuplicateVertex, + ZeroArea, + SelfIntersecting, + NonConvex, +} + +pub fn signed_area_xz(vertices: &[Vec3]) -> f32 { + if vertices.len() < 3 { + return 0.0; + } + vertices + .iter() + .zip(vertices.iter().cycle().skip(1)) + .take(vertices.len()) + .map(|(a, b)| a.x * b.z - b.x * a.z) + .sum::() + * 0.5 +} + +pub fn validate_floor_polygon(vertices: &[Vec3]) -> Result<(), BrushPolygonError> { + if vertices.len() < 3 { + return Err(BrushPolygonError::TooFewVertices); + } + if !vertices.iter().all(|vertex| vertex.is_finite()) { + return Err(BrushPolygonError::NonFiniteVertex); + } + for i in 0..vertices.len() { + for j in (i + 1)..vertices.len() { + if vertices[i].xz().distance_squared(vertices[j].xz()) <= EPSILON * EPSILON { + return Err(BrushPolygonError::DuplicateVertex); + } + } + } + let area = signed_area_xz(vertices); + if self_intersects_xz(vertices) { + return Err(BrushPolygonError::SelfIntersecting); + } + if area.abs() <= EPSILON { + return Err(BrushPolygonError::ZeroArea); + } + if !is_convex_xz(vertices) { + return Err(BrushPolygonError::NonConvex); + } + Ok(()) +} + +fn self_intersects_xz(vertices: &[Vec3]) -> bool { + for a in 0..vertices.len() { + let b = (a + 1) % vertices.len(); + for c in (a + 1)..vertices.len() { + let d = (c + 1) % vertices.len(); + if a == c || a == d || b == c || b == d { + continue; + } + if segments_intersect_xz(vertices[a], vertices[b], vertices[c], vertices[d]) { + return true; + } + } + } + false +} + +fn segments_intersect_xz(a: Vec3, b: Vec3, c: Vec3, d: Vec3) -> bool { + let ab_c = orient_xz(a, b, c); + let ab_d = orient_xz(a, b, d); + let cd_a = orient_xz(c, d, a); + let cd_b = orient_xz(c, d, b); + ab_c.signum() != ab_d.signum() && cd_a.signum() != cd_b.signum() +} + +fn is_convex_xz(vertices: &[Vec3]) -> bool { + let winding = signed_area_xz(vertices).signum(); + vertices.iter().enumerate().all(|(index, current)| { + let prev = vertices[(index + vertices.len() - 1) % vertices.len()]; + let next = vertices[(index + 1) % vertices.len()]; + let turn = orient_xz(prev, *current, next); + turn.abs() <= EPSILON || turn.signum() == winding + }) +} + +fn orient_xz(a: Vec3, b: Vec3, c: Vec3) -> f32 { + let ab = b.xz() - a.xz(); + let ac = c.xz() - a.xz(); + ab.x * ac.y - ab.y * ac.x +} + +#[cfg(test)] +mod tests { + use super::*; + + fn p(x: f32, z: f32) -> Vec3 { + Vec3::new(x, 0.0, z) + } + + #[test] + fn validates_convex_floor_polygon() { + assert!(validate_floor_polygon(&[p(-1.0, -1.0), p(1.0, -1.0), p(1.0, 1.0)]).is_ok()); + } + + #[test] + fn rejects_self_intersecting_polygon() { + assert_eq!( + validate_floor_polygon(&[p(-1.0, -1.0), p(1.0, 1.0), p(1.0, -1.0), p(-1.0, 1.0),]), + Err(BrushPolygonError::SelfIntersecting) + ); + } + + #[test] + fn rejects_non_convex_polygon() { + assert_eq!( + validate_floor_polygon(&[ + p(-1.0, -1.0), + p(1.0, -1.0), + p(0.0, 0.0), + p(1.0, 1.0), + p(-1.0, 1.0), + ]), + Err(BrushPolygonError::NonConvex) + ); + } +} diff --git a/crates/shared/src/components.rs b/crates/shared/src/components.rs index d204a2d..a179f3e 100644 --- a/crates/shared/src/components.rs +++ b/crates/shared/src/components.rs @@ -426,6 +426,49 @@ impl BrushDesc { receive_shadows: true, } } + + pub fn extruded_prism(base_vertices: &[Vec3], height: f32) -> Option { + if crate::brush_math::validate_floor_polygon(base_vertices).is_err() { + return None; + } + let height = height.max(0.001); + let mut base = base_vertices.to_vec(); + if crate::brush_math::signed_area_xz(&base) < 0.0 { + base.reverse(); + } + let top: Vec = base + .iter() + .map(|vertex| *vertex + Vec3::Y * height) + .collect(); + let mut bottom = base.clone(); + bottom.reverse(); + + let mut faces = Vec::with_capacity(base.len() + 2); + faces.push(brush_face("face:+y", Vec3::Y, top)); + faces.push(brush_face("face:-y", Vec3::NEG_Y, bottom)); + for index in 0..base.len() { + let next = (index + 1) % base.len(); + let a = base[index]; + let b = base[next]; + let edge = b - a; + let normal = Vec3::new(edge.z, 0.0, -edge.x).normalize_or_zero(); + if normal.length_squared() <= f32::EPSILON { + return None; + } + faces.push(brush_face( + &format!("face:side:{index}"), + normal, + vec![a, a + Vec3::Y * height, b + Vec3::Y * height, b], + )); + } + + Some(Self { + kind: BrushKind::Additive, + faces, + cast_shadows: true, + receive_shadows: true, + }) + } } impl Default for BrushDesc { diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 0caf62c..463ae0c 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -5,6 +5,7 @@ //! from them by hydration systems in both the game and editor. mod actor; +pub mod brush_math; mod components; mod hydration; mod material_asset; diff --git a/docs/editor/README.md b/docs/editor/README.md index 87d0ef1..4d5ee64 100644 --- a/docs/editor/README.md +++ b/docs/editor/README.md @@ -44,6 +44,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a - **Asset browser** mirrors the on-disk `assets/` directory with project tree, breadcrumb, search/filter/sort, grid/list views, texture/model/material thumbnails, a details pane, and context-aware row/menu actions; narrow docks prioritize content, keep the root panel fixed, switch list view to a compact single-column layout, and hide tree/details panes when they would crowd the content area. The footer stays pinned while only the project tree, asset content, and details panels scroll. **Built-ins** holds spawnable primitives and lights. **Materials** folder scans `assets/materials/*.ron`, renders material thumbnails on a sphere using `MaterialDesc`, and exposes shader-schema-driven parameters/textures in the details editor; **Shaders** holds shader schema RON files. glTF/GLB/FBX rows can expand into a shelf of normalized embedded mesh, material, and texture subassets with independent generated thumbnails. Mesh subassets can be selected, dragged into the viewport, or placed from details/context menus; material subassets render source-material spheres; texture subassets can be applied to the selected actor. Model import settings are staged with **Apply** / **Revert**, asset context menus can regenerate thumbnails, material asset details edit shared `MaterialAsset` fields, and file asset deletion moves sources/generated artifacts into `assets/.trash/`. - **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. - **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). @@ -53,7 +54,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a - **World lighting** has two layers: project default sun/ambient settings, and optional scene-authored directional `LightDesc` overrides. **Scene → Lighting** menu and **Rendering** panel (Window) restore project sun or bake a scene directional from project settings. **Apply** in Project Settings updates ambient and sun illuminance live. - **Rendering** — `GiMode` + post-process volumes; see [rendering.md](rendering.md). **Window → Rendering** shows requested/effective active-camera stack, fallback reason, Solari eligibility, viewport GI badge, and volume HUD. - **Inspector** shows a responsive header, **Transform**, registered authoring component cards, and an Add Component footer (`actor_inspector`) that expands an inline search shelf above the button; only the component body scrolls. Rows wrap or stack inside narrow docks instead of forcing the panel wider, and legacy authoring sections use the same component-card shell. Component card carets collapse/expand for the current editor session, status dots toggle persisted active state, and triple-dot menus provide reset, copy/paste values, move, remove, and documentation slots. Inactive authoring components remain in the scene but are skipped by hydration/runtime queries, stripping their generated meshes, lights, physics, or post-process effects. The Add Component shelf is registry-driven with persistent search, category groups, descriptions, hydration hints, duplicate/conflict disabled states, and recommendation hints. `StaticMeshRenderer` uses concept-style slot cards with model thumbnails, imported mesh/source-material selectors, visibility, and shadow flags. Actor material authoring lives in the `Authoring Material` component, whose texture refs use Asset Browser picker/drop controls. Empty source material refs display the inherited source material from the generated static mesh artifact as **Source default**; selector **Browse** assigns explicit normalized imported sub-assets, **Locate** focuses the owning asset in the Asset Browser, and **Clear** returns optional material slots to the inherited source default. `ColliderDesc` and `RigidBodyDesc` are separate component cards. Game crates add sections via `ActorInspectorSection` + `game::editor_ext::actor_inspector_section_ids()`. -- **Command palette** (`Ctrl+P`): focuses the filter on open, selects the first filtered command, supports arrow-key selection and Enter to run. Commands include `scene.reset_lighting`, `rendering.create_volume`, `rendering.select_volumes_at_camera`, `rendering.focus_active_volumes`, `selection.group`, `selection.focus`, `selection.reset_transform`, and play commands. +- **Command palette** (`Ctrl+P`): focuses the filter on open, selects the first filtered command, supports arrow-key selection and Enter to run. Commands include `scene.reset_lighting`, `brush.draw`, `rendering.create_volume`, `rendering.select_volumes_at_camera`, `rendering.focus_active_volumes`, `selection.group`, `selection.focus`, `selection.reset_transform`, and play commands. - **Editor input routing** gives egui text fields first claim on keyboard input. Viewport shortcut keys require viewport pointer focus and are suspended while RMB/MMB camera navigation is active. - **ActorKind** is required on saved level objects (schema v2). Save strips hydrated ECS before writing `.scn.ron`. - **Scene visualizers** expose actor root icons, colliders, lights, player spawns, prefab/model anchors, Project Sun, and runtime player/camera markers without changing saved scene data. Viewport options include actor icon size and transform gizmo size sliders. diff --git a/docs/editor/brushes.md b/docs/editor/brushes.md index 48e2496..ad17354 100644 --- a/docs/editor/brushes.md +++ b/docs/editor/brushes.md @@ -9,6 +9,7 @@ Brushes are persisted blockout geometry stored as `ActorKind::Brush + BrushDesc` - Hydration builds generated mesh children from active brush components and strips those children before scene save. - Brush actors validate separately from primitives and imported/static mesh actors. - 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. ## Current Limits