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 | | `F2` in Hierarchy | Rename selection |
| `W` / `E` / `R` | Translate / rotate / scale gizmo | | `W` / `E` / `R` | Translate / rotate / scale gizmo |
| `X` | Toggle world/local gizmo orientation | | `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 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 | | 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 | | `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. subasset places that part through the same static mesh renderer path.
- Brush actors are persisted as `ActorKind::Brush + BrushDesc`; the MVP hydrates additive convex - 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. 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. - 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. - 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 - **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::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::theme::{apply_editor_theme, PANEL_BG, TEXT, TEXT_DIM};
use crate::ui::UiState; use crate::ui::UiState;
use crate::viewport::brush_tool::start_draw_brush_tool;
/// Named editor command invokable from the palette and BRP. /// Named editor command invokable from the palette and BRP.
pub trait EditorCommand: Send + Sync { 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(CreatePostProcessVolumeCommand));
registry.register(Box::new(FocusActiveVolumesCommand)); registry.register(Box::new(FocusActiveVolumesCommand));
registry.register(Box::new(SelectVolumesAtCameraCommand)); registry.register(Box::new(SelectVolumesAtCameraCommand));
registry.register(Box::new(DrawBrushCommand));
} }
fn run_palette_commands(world: &mut World) { 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; struct CreatePostProcessVolumeCommand;
impl EditorCommand for 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_schema;
pub use scene::scene_view; pub use scene::scene_view;
pub use viewport::actor_icons; pub use viewport::actor_icons;
pub use viewport::brush_tool;
pub use viewport::camera; pub use viewport::camera;
pub use viewport::gizmos; pub use viewport::gizmos;
pub use viewport::render_view; pub use viewport::render_view;
@ -47,6 +48,7 @@ use transform_gizmo_bevy::prelude::TransformGizmoPlugin;
use actor_icons::ActorIconsPlugin; use actor_icons::ActorIconsPlugin;
use asset_db::AssetDbPlugin; use asset_db::AssetDbPlugin;
use brush_tool::BrushToolPlugin;
use camera::EditorCameraPlugin; use camera::EditorCameraPlugin;
use extensibility::ExtensibilityPlugin; use extensibility::ExtensibilityPlugin;
use gizmos::EditorGizmoPlugin; use gizmos::EditorGizmoPlugin;
@ -89,6 +91,7 @@ impl PluginGroup for EditorPluginGroup {
.add(SceneViewPlugin) .add(SceneViewPlugin)
.add(PlaySessionPlugin) .add(PlaySessionPlugin)
.add(ViewportPlugin) .add(ViewportPlugin)
.add(BrushToolPlugin)
.add(EditorCameraPlugin) .add(EditorCameraPlugin)
.add(ActorIconsPlugin) .add(ActorIconsPlugin)
.add(EditorSelectionPlugin) .add(EditorSelectionPlugin)

View File

@ -8,6 +8,7 @@ use crate::history::spawn_with_history;
use crate::scene_io::{SceneIo, SceneIoRequest}; use crate::scene_io::{SceneIo, SceneIoRequest};
use crate::state::{EditorMode, PlayPaused, PlayPossession}; use crate::state::{EditorMode, PlayPaused, PlayPossession};
use crate::ui::UiState; use crate::ui::UiState;
use crate::viewport::brush_tool::start_draw_brush_tool;
use crate::viewport::{editor_spawn_ground_position, snap_translation, ViewportSettings}; use crate::viewport::{editor_spawn_ground_position, snap_translation, ViewportSettings};
use shared::PrimitiveShape; use shared::PrimitiveShape;
@ -87,6 +88,9 @@ fn left_toolbar(world: &mut World, ui: &mut egui::Ui) {
let pos = camera_ground_spawn(world); let pos = camera_ground_spawn(world);
spawn_with_history(world, light_snapshot("Point Light", pos)); 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); 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. //! Viewport chrome, editor camera, selection, gizmos, and render views.
pub mod actor_icons; pub mod actor_icons;
pub mod brush_tool;
pub mod camera; pub mod camera;
pub mod gizmos; pub mod gizmos;
mod panel; mod panel;
@ -12,5 +13,6 @@ pub mod viewport_mode;
pub mod visualizers; pub mod visualizers;
pub use actor_icons::ActorIconsPlugin; pub use actor_icons::ActorIconsPlugin;
pub use brush_tool::{BrushToolPlugin, BrushToolState};
pub use panel::*; pub use panel::*;
pub use viewport_mode::EditorViewportMode; 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::hierarchy_state::HierarchyPanelState;
use crate::ui::UiState; use crate::ui::UiState;
use crate::viewport::actor_icons::ActorIconProxy; use crate::viewport::actor_icons::ActorIconProxy;
use crate::viewport::brush_tool::BrushToolState;
use crate::viewport::ViewportDisplayMode; use crate::viewport::ViewportDisplayMode;
use crate::visualizers::EditorVisualizerProxy; use crate::visualizers::EditorVisualizerProxy;
use bevy_egui::egui; use bevy_egui::egui;
@ -102,6 +103,7 @@ fn handle_pick_events(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>, buttons: Res<ButtonInput<MouseButton>>,
display: Res<ViewportDisplayMode>, display: Res<ViewportDisplayMode>,
brush_tool: Res<BrushToolState>,
gizmo_targets: Query<&GizmoTarget>, gizmo_targets: Query<&GizmoTarget>,
hierarchy: Option<Res<HierarchyPanelState>>, hierarchy: Option<Res<HierarchyPanelState>>,
) -> Result { ) -> Result {
@ -122,6 +124,10 @@ fn handle_pick_events(
return Ok(()); return Ok(());
} }
if brush_tool.active {
return Ok(());
}
if gizmo_targets if gizmo_targets
.iter() .iter()
.any(|g| GizmoTarget::is_focused(g) || GizmoTarget::is_active(g)) .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, 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 { impl Default for BrushDesc {

View File

@ -5,6 +5,7 @@
//! from them by hydration systems in both the game and editor. //! from them by hydration systems in both the game and editor.
mod actor; mod actor;
pub mod brush_math;
mod components; mod components;
mod hydration; mod hydration;
mod material_asset; 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/`. - **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. - **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). - **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. - **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. - **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). - **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. - **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. - **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()`. - **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. - **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`. - **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. - **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. - 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. - 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. - 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 ## Current Limits