Add draw brush tool
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run

This commit is contained in:
Rbanh 2026-06-06 05:52:52 -04:00
parent 9f95ba3082
commit fcd34f9079
12 changed files with 570 additions and 1 deletions

View File

@ -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

View File

@ -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<EditorCommandRegistry>) {
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<String> {
(*world.resource::<State<EditorMode>>().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 {

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_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)

View File

@ -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);

View File

@ -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<Vec3>,
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::<BrushToolState>().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::<BrushToolState>().start();
world.resource_mut::<SceneIo>().status =
"Draw Brush: click floor points, Enter creates brush".to_string();
if let Some(mut active_operator) = world.get_resource_mut::<ActiveOperator>() {
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::<ViewportClick>() {
viewport_click.0 = None;
}
}
#[allow(clippy::too_many_arguments)]
fn brush_tool_input(
mut commands: Commands,
mut tool: ResMut<BrushToolState>,
mut viewport_click: ResMut<ViewportClick>,
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>,
settings: Res<ViewportSettings>,
cameras: Query<(&Camera, &GlobalTransform), With<EditorCamera>>,
) -> 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<BrushToolState>,
ui_state: Res<UiState>,
cameras: Query<(&Camera, &GlobalTransform), With<EditorCamera>>,
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<EditorCamera>>,
pointer_pos: egui::Pos2,
scene_rect: egui::Rect,
) -> Option<Vec3> {
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<EditorEntitySnapshot, String> {
validate_floor_polygon(points).map_err(|error| format!("{error:?}"))?;
let center = polygon_center(points);
let local_points: Vec<Vec3> = 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<String>,
) {
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());
}
}

View File

@ -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;

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_tool::BrushToolState;
use crate::viewport::ViewportDisplayMode;
use crate::visualizers::EditorVisualizerProxy;
use bevy_egui::egui;
@ -102,6 +103,7 @@ fn handle_pick_events(
keys: Res<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>,
display: Res<ViewportDisplayMode>,
brush_tool: Res<BrushToolState>,
gizmo_targets: Query<&GizmoTarget>,
hierarchy: Option<Res<HierarchyPanelState>>,
) -> 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))

View File

@ -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::<f32>()
* 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)
);
}
}

View File

@ -426,6 +426,49 @@ impl BrushDesc {
receive_shadows: true,
}
}
pub fn extruded_prism(base_vertices: &[Vec3], height: f32) -> Option<Self> {
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<Vec3> = 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 {

View File

@ -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;

View File

@ -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.

View File

@ -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