This commit is contained in:
parent
9f95ba3082
commit
fcd34f9079
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
350
crates/editor/src/viewport/brush_tool.rs
Normal file
350
crates/editor/src/viewport/brush_tool.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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))
|
||||
|
||||
131
crates/shared/src/brush_math.rs
Normal file
131
crates/shared/src/brush_math.rs
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user