Improve quick brush creation workflow
This commit is contained in:
parent
f09d35e54e
commit
48b1448c57
@ -187,8 +187,11 @@ 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.
|
||||
- Draw Brush mode (`B` or toolbar pencil) places snapped floor points; `Enter` locks the outline,
|
||||
mouse up/down adjusts height, and `Enter` or left-click commits additive prism brushes. Simple
|
||||
concave outlines decompose into convex brush parts, while self-intersections are blocked with
|
||||
status text. The viewport shows quick brush key hints while drawing. `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
|
||||
|
||||
@ -50,6 +50,10 @@ pub enum EditorCommand {
|
||||
snapshot: EditorEntitySnapshot,
|
||||
entity: Option<Entity>,
|
||||
},
|
||||
SpawnMany {
|
||||
snapshots: Vec<EditorEntitySnapshot>,
|
||||
entities: Vec<Entity>,
|
||||
},
|
||||
Despawn {
|
||||
snapshot: EditorEntitySnapshot,
|
||||
entity: Option<Entity>,
|
||||
@ -160,6 +164,7 @@ impl EditorCommand {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
EditorCommand::Spawn { .. } => "Spawn",
|
||||
EditorCommand::SpawnMany { .. } => "Spawn Brushes",
|
||||
EditorCommand::Despawn { .. } => "Delete",
|
||||
EditorCommand::Duplicate { .. } => "Duplicate",
|
||||
EditorCommand::Rename { .. } => "Rename",
|
||||
|
||||
@ -274,6 +274,34 @@ pub fn spawn_with_history(world: &mut World, mut snapshot: EditorEntitySnapshot)
|
||||
entity
|
||||
}
|
||||
|
||||
pub fn spawn_many_with_history(
|
||||
world: &mut World,
|
||||
snapshots: impl IntoIterator<Item = EditorEntitySnapshot>,
|
||||
) -> Vec<Entity> {
|
||||
let mut snapshots: Vec<EditorEntitySnapshot> = snapshots.into_iter().collect();
|
||||
if snapshots.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut sibling_index = next_sibling_index(world, None);
|
||||
for snapshot in &mut snapshots {
|
||||
snapshot.hierarchy_sibling_index = sibling_index;
|
||||
sibling_index += 1;
|
||||
}
|
||||
let entities: Vec<Entity> = snapshots
|
||||
.iter()
|
||||
.map(|snapshot| spawn_snapshot(world, snapshot))
|
||||
.collect();
|
||||
push_history(
|
||||
world,
|
||||
EditorCommand::SpawnMany {
|
||||
snapshots,
|
||||
entities: entities.clone(),
|
||||
},
|
||||
);
|
||||
select_many(world, &entities);
|
||||
entities
|
||||
}
|
||||
|
||||
pub fn delete_entities_with_history(world: &mut World, entities: &[Entity]) {
|
||||
let mut deleted = Vec::new();
|
||||
for entity in entities {
|
||||
@ -752,6 +780,12 @@ fn undo_command(world: &mut World, command: &mut EditorCommand) {
|
||||
despawn_entity(world, entity.take());
|
||||
clear_selection(world);
|
||||
}
|
||||
EditorCommand::SpawnMany { entities, .. } => {
|
||||
for entity in std::mem::take(entities) {
|
||||
despawn_entity(world, Some(entity));
|
||||
}
|
||||
clear_selection(world);
|
||||
}
|
||||
EditorCommand::Despawn { snapshot, entity } => {
|
||||
let spawned = spawn_snapshot(world, snapshot);
|
||||
*entity = Some(spawned);
|
||||
@ -847,6 +881,17 @@ fn redo_command(world: &mut World, command: &mut EditorCommand) {
|
||||
*entity = Some(spawned);
|
||||
select_one(world, spawned);
|
||||
}
|
||||
EditorCommand::SpawnMany {
|
||||
snapshots,
|
||||
entities,
|
||||
} => {
|
||||
let spawned: Vec<Entity> = snapshots
|
||||
.iter()
|
||||
.map(|snapshot| spawn_snapshot(world, snapshot))
|
||||
.collect();
|
||||
*entities = spawned.clone();
|
||||
select_many(world, &spawned);
|
||||
}
|
||||
EditorCommand::Despawn { entity, .. } => {
|
||||
despawn_entity(world, entity.take());
|
||||
clear_selection(world);
|
||||
|
||||
@ -16,6 +16,7 @@ use crate::selection::ViewportClick;
|
||||
use crate::state::PlayPossession;
|
||||
use crate::viewport::actor_icons::ActorIconSettings;
|
||||
use crate::viewport::brush_edit::BrushEditMode;
|
||||
use crate::viewport::brush_tool::{BrushToolPhase, BrushToolState};
|
||||
use crate::viewport::{
|
||||
snap_translation, viewport_ground_position, EditorViewportMode, ViewportDisplayMode,
|
||||
ViewportSettings,
|
||||
@ -109,6 +110,7 @@ pub fn viewport_tab_ui(
|
||||
}
|
||||
|
||||
scene_view_overlay_toolbar(world, ui.ctx(), rect);
|
||||
scene_view_brush_draw_hints(world, ui.ctx(), rect);
|
||||
scene_view_mode_badge(world, ui.ctx(), rect);
|
||||
scene_view_brush_mode_badge(world, ui.ctx(), rect);
|
||||
scene_view_gi_badge(world, ui.ctx(), rect);
|
||||
@ -177,6 +179,86 @@ fn scene_view_brush_mode_badge(world: &World, ctx: &egui::Context, scene_rect: e
|
||||
});
|
||||
}
|
||||
|
||||
fn scene_view_brush_draw_hints(world: &World, ctx: &egui::Context, scene_rect: egui::Rect) {
|
||||
let Some(tool) = world.get_resource::<BrushToolState>() else {
|
||||
return;
|
||||
};
|
||||
if !tool.active {
|
||||
return;
|
||||
}
|
||||
let hint = match tool.phase {
|
||||
BrushToolPhase::Outline if tool.vertices.len() < 3 => "Place points".to_string(),
|
||||
BrushToolPhase::Outline => "Set height".to_string(),
|
||||
BrushToolPhase::Height => format!("{:.1}m", tool.height),
|
||||
};
|
||||
egui::Area::new(egui::Id::new("scene_view_brush_draw_hints"))
|
||||
.fixed_pos(scene_rect.left_top() + egui::vec2(8.0, 52.0))
|
||||
.interactable(false)
|
||||
.show(ctx, |ui| {
|
||||
egui::Frame::new()
|
||||
.fill(egui::Color32::from_rgba_unmultiplied(18, 22, 26, 220))
|
||||
.stroke(egui::Stroke::new(
|
||||
1.0,
|
||||
egui::Color32::from_rgba_unmultiplied(95, 150, 190, 180),
|
||||
))
|
||||
.corner_radius(egui::CornerRadius::same(4))
|
||||
.inner_margin(egui::Margin::symmetric(8, 6))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
egui::RichText::new("Draw Brush")
|
||||
.color(egui::Color32::from_rgb(170, 220, 255))
|
||||
.strong(),
|
||||
);
|
||||
ui.label(
|
||||
egui::RichText::new(format!("{} pts", tool.vertices.len()))
|
||||
.color(egui::Color32::from_rgb(190, 200, 205))
|
||||
.small(),
|
||||
);
|
||||
ui.label(
|
||||
egui::RichText::new(hint)
|
||||
.color(egui::Color32::from_rgb(150, 220, 170))
|
||||
.small(),
|
||||
);
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal_wrapped(|ui| match tool.phase {
|
||||
BrushToolPhase::Outline => {
|
||||
key_hint(ui, "LMB", "Point");
|
||||
key_hint(ui, "Enter", "Height");
|
||||
key_hint(ui, "Backspace", "Remove");
|
||||
key_hint(ui, "Esc", "Cancel");
|
||||
key_hint(ui, "RMB", "Cancel");
|
||||
}
|
||||
BrushToolPhase::Height => {
|
||||
key_hint(ui, "Mouse Up/Down", "Height");
|
||||
key_hint(ui, "Enter", "Create");
|
||||
key_hint(ui, "LMB", "Create");
|
||||
key_hint(ui, "Backspace", "Outline");
|
||||
key_hint(ui, "Esc", "Cancel");
|
||||
key_hint(ui, "RMB", "Cancel");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn key_hint(ui: &mut egui::Ui, key: &str, label: &str) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
egui::RichText::new(key)
|
||||
.monospace()
|
||||
.color(egui::Color32::from_rgb(232, 238, 242))
|
||||
.background_color(egui::Color32::from_rgba_unmultiplied(45, 52, 58, 220)),
|
||||
);
|
||||
ui.label(
|
||||
egui::RichText::new(label)
|
||||
.color(egui::Color32::from_rgb(190, 200, 205))
|
||||
.small(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn scene_view_gi_badge(world: &World, ctx: &egui::Context, scene_rect: egui::Rect) {
|
||||
let Some(profile) = world.get_resource::<settings::ActiveCameraRenderProfile>() else {
|
||||
return;
|
||||
|
||||
@ -3,11 +3,12 @@
|
||||
use bevy::prelude::*;
|
||||
use bevy_egui::{egui, EguiContexts};
|
||||
use shared::{
|
||||
brush_math::validate_floor_polygon, ActorKind, BrushDesc, EditorVisibility, MaterialDesc,
|
||||
brush_math::{decompose_floor_polygon_to_convex, BrushPolygonError},
|
||||
ActorKind, BrushDesc, EditorVisibility, MaterialDesc,
|
||||
};
|
||||
|
||||
use crate::camera::EditorCamera;
|
||||
use crate::history::{spawn_with_history, EditorEntitySnapshot};
|
||||
use crate::history::{spawn_many_with_history, EditorEntitySnapshot};
|
||||
use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus};
|
||||
use crate::scene_io::SceneIo;
|
||||
use crate::selection::ViewportClick;
|
||||
@ -16,21 +17,35 @@ 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_BRUSH_HEIGHT: f32 = 0.1;
|
||||
const HEIGHT_PIXELS_TO_WORLD: f32 = 0.03;
|
||||
const MIN_COMMIT_VERTICES: usize = 3;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BrushToolPhase {
|
||||
Outline,
|
||||
Height,
|
||||
}
|
||||
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct BrushToolState {
|
||||
pub active: bool,
|
||||
pub phase: BrushToolPhase,
|
||||
pub vertices: Vec<Vec3>,
|
||||
pub height: f32,
|
||||
height_anchor_pointer_y: Option<f32>,
|
||||
height_anchor_value: f32,
|
||||
}
|
||||
|
||||
impl Default for BrushToolState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
active: false,
|
||||
phase: BrushToolPhase::Outline,
|
||||
vertices: Vec::new(),
|
||||
height: DEFAULT_BRUSH_HEIGHT,
|
||||
height_anchor_pointer_y: None,
|
||||
height_anchor_value: DEFAULT_BRUSH_HEIGHT,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -38,13 +53,47 @@ impl Default for BrushToolState {
|
||||
impl BrushToolState {
|
||||
pub fn start(&mut self) {
|
||||
self.active = true;
|
||||
self.phase = BrushToolPhase::Outline;
|
||||
self.vertices.clear();
|
||||
self.height = DEFAULT_BRUSH_HEIGHT;
|
||||
self.height_anchor_pointer_y = None;
|
||||
self.height_anchor_value = DEFAULT_BRUSH_HEIGHT;
|
||||
}
|
||||
|
||||
pub fn cancel(&mut self) {
|
||||
self.active = false;
|
||||
self.phase = BrushToolPhase::Outline;
|
||||
self.vertices.clear();
|
||||
self.height = DEFAULT_BRUSH_HEIGHT;
|
||||
self.height_anchor_pointer_y = None;
|
||||
self.height_anchor_value = DEFAULT_BRUSH_HEIGHT;
|
||||
}
|
||||
|
||||
fn start_height_phase(&mut self, pointer_pos: Option<egui::Pos2>) {
|
||||
self.phase = BrushToolPhase::Height;
|
||||
self.height = DEFAULT_BRUSH_HEIGHT;
|
||||
self.height_anchor_value = DEFAULT_BRUSH_HEIGHT;
|
||||
self.height_anchor_pointer_y = pointer_pos.map(|pos| pos.y);
|
||||
}
|
||||
|
||||
fn return_to_outline_phase(&mut self) {
|
||||
self.phase = BrushToolPhase::Outline;
|
||||
self.height = DEFAULT_BRUSH_HEIGHT;
|
||||
self.height_anchor_pointer_y = None;
|
||||
self.height_anchor_value = DEFAULT_BRUSH_HEIGHT;
|
||||
}
|
||||
|
||||
fn update_height_from_pointer(&mut self, pointer_pos: Option<egui::Pos2>) {
|
||||
if self.phase != BrushToolPhase::Height {
|
||||
return;
|
||||
}
|
||||
let Some(pointer_pos) = pointer_pos else {
|
||||
return;
|
||||
};
|
||||
let anchor_y = self.height_anchor_pointer_y.get_or_insert(pointer_pos.y);
|
||||
let height =
|
||||
self.height_anchor_value + (*anchor_y - pointer_pos.y) * HEIGHT_PIXELS_TO_WORLD;
|
||||
self.height = quantize_height(height);
|
||||
}
|
||||
}
|
||||
|
||||
@ -62,12 +111,12 @@ impl Plugin for BrushToolPlugin {
|
||||
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();
|
||||
"Draw Brush: click floor points, Enter sets height".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",
|
||||
"Click floor points, Enter sets height, Esc cancels",
|
||||
);
|
||||
}
|
||||
if let Some(mut viewport_click) = world.get_resource_mut::<ViewportClick>() {
|
||||
@ -105,11 +154,11 @@ fn brush_tool_input(
|
||||
|
||||
if keyboard_available && keys.just_pressed(KeyCode::KeyB) {
|
||||
tool.start();
|
||||
scene_io.status = "Draw Brush: click floor points, Enter creates brush".to_string();
|
||||
scene_io.status = "Draw Brush: click floor points, Enter sets height".to_string();
|
||||
set_brush_tool_status(
|
||||
&mut active_operator,
|
||||
OperatorPhase::Preview,
|
||||
"Click floor points, Enter creates brush, Esc cancels",
|
||||
"Click floor points, Enter sets height, Esc cancels",
|
||||
);
|
||||
viewport_click.0 = None;
|
||||
return Ok(());
|
||||
@ -118,6 +167,7 @@ fn brush_tool_input(
|
||||
if !tool.active {
|
||||
return Ok(());
|
||||
}
|
||||
tool.update_height_from_pointer(ui_state.viewport_pointer_pos);
|
||||
|
||||
if (!egui_keyboard_busy && keys.just_pressed(KeyCode::Escape))
|
||||
|| buttons.just_pressed(MouseButton::Right)
|
||||
@ -130,48 +180,83 @@ fn brush_tool_input(
|
||||
}
|
||||
|
||||
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()),
|
||||
);
|
||||
if tool.phase == BrushToolPhase::Height {
|
||||
tool.return_to_outline_phase();
|
||||
} else {
|
||||
tool.vertices.pop();
|
||||
}
|
||||
let status = draw_brush_status(&tool);
|
||||
scene_io.status = status.clone();
|
||||
set_brush_tool_status(&mut active_operator, OperatorPhase::Preview, status);
|
||||
viewport_click.0 = None;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if tool.phase == BrushToolPhase::Height {
|
||||
if buttons.just_pressed(MouseButton::Left)
|
||||
|| (!egui_keyboard_busy && keys.just_pressed(KeyCode::Enter))
|
||||
{
|
||||
match brush_snapshots_from_points(&tool.vertices, tool.height) {
|
||||
Ok(snapshots) => {
|
||||
let brush_count = snapshots.len();
|
||||
let height = tool.height;
|
||||
commands.queue(move |world: &mut World| {
|
||||
spawn_many_with_history(world, snapshots);
|
||||
});
|
||||
tool.cancel();
|
||||
scene_io.status = brush_created_status(brush_count, height);
|
||||
set_brush_tool_status(
|
||||
&mut active_operator,
|
||||
OperatorPhase::Committed,
|
||||
brush_committed_status(brush_count, height),
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
let message = format!("Draw Brush failed: {error}");
|
||||
warn!("{message}; points={:?}", tool.vertices);
|
||||
scene_io.status = message.clone();
|
||||
set_brush_tool_status(&mut active_operator, OperatorPhase::Blocked, message);
|
||||
}
|
||||
}
|
||||
viewport_click.0 = None;
|
||||
return Ok(());
|
||||
}
|
||||
viewport_click.0 = None;
|
||||
let status = draw_brush_status(&tool);
|
||||
scene_io.status = status.clone();
|
||||
set_brush_tool_status(&mut active_operator, OperatorPhase::Preview, status);
|
||||
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()),
|
||||
);
|
||||
let status = draw_brush_status(&tool);
|
||||
scene_io.status = status.clone();
|
||||
set_brush_tool_status(&mut active_operator, OperatorPhase::Preview, status);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
match decompose_floor_polygon_to_convex(&tool.vertices) {
|
||||
Ok(parts) => {
|
||||
let part_count = parts.len();
|
||||
tool.start_height_phase(ui_state.viewport_pointer_pos);
|
||||
scene_io.status = draw_brush_status(&tool);
|
||||
set_brush_tool_status(
|
||||
&mut active_operator,
|
||||
OperatorPhase::Committed,
|
||||
"Committed brush.draw",
|
||||
OperatorPhase::Preview,
|
||||
height_phase_status(part_count, tool.height),
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
scene_io.status = format!("Draw Brush failed: {error}");
|
||||
set_brush_tool_status(&mut active_operator, OperatorPhase::Blocked, error);
|
||||
let message = format!("Draw Brush failed: {}", floor_polygon_error_message(error));
|
||||
warn!("{message}; points={:?}", tool.vertices);
|
||||
scene_io.status = message.clone();
|
||||
set_brush_tool_status(&mut active_operator, OperatorPhase::Blocked, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -182,6 +267,7 @@ fn brush_tool_input(
|
||||
fn draw_brush_tool_preview(
|
||||
tool: Res<BrushToolState>,
|
||||
ui_state: Res<UiState>,
|
||||
settings: Res<ViewportSettings>,
|
||||
cameras: Query<(&Camera, &GlobalTransform), With<EditorCamera>>,
|
||||
mut gizmos: Gizmos,
|
||||
) {
|
||||
@ -189,15 +275,20 @@ fn draw_brush_tool_preview(
|
||||
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 tool.phase == BrushToolPhase::Outline {
|
||||
if let Some(pointer_pos) = ui_state.viewport_pointer_pos {
|
||||
if let Some(mut point) =
|
||||
pointer_floor_position(&cameras, pointer_pos, ui_state.viewport_rect)
|
||||
{
|
||||
point = snap_translation(point, &settings);
|
||||
points.push(point);
|
||||
}
|
||||
}
|
||||
}
|
||||
if points.is_empty() {
|
||||
return;
|
||||
}
|
||||
let color = Color::srgb(0.2, 0.72, 1.0);
|
||||
let color = brush_preview_color(&points);
|
||||
for point in &points {
|
||||
gizmos.sphere(*point + Vec3::Y * 0.02, 0.08, color);
|
||||
gizmos.line(*point, *point + Vec3::Y * tool.height, color);
|
||||
@ -219,6 +310,11 @@ fn draw_brush_tool_preview(
|
||||
first + Vec3::Y * tool.height,
|
||||
color,
|
||||
);
|
||||
if let Ok(parts) = decompose_floor_polygon_to_convex(&points) {
|
||||
if parts.len() > 1 {
|
||||
draw_decomposition_preview(&parts, tool.height, &mut gizmos);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -240,11 +336,31 @@ fn pointer_floor_position(
|
||||
Some(ray.origin + direction * t.max(0.0))
|
||||
}
|
||||
|
||||
fn brush_snapshot_from_points(
|
||||
fn brush_snapshots_from_points(
|
||||
points: &[Vec3],
|
||||
height: f32,
|
||||
) -> Result<Vec<EditorEntitySnapshot>, String> {
|
||||
let parts = decompose_floor_polygon_to_convex(points).map_err(floor_polygon_error_message)?;
|
||||
let part_count = parts.len();
|
||||
parts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, part)| {
|
||||
let name = if part_count == 1 {
|
||||
"Brush".to_string()
|
||||
} else {
|
||||
format!("Brush Part {}", index + 1)
|
||||
};
|
||||
brush_snapshot_from_convex_points(part, height, name)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn brush_snapshot_from_convex_points(
|
||||
points: &[Vec3],
|
||||
height: f32,
|
||||
name: String,
|
||||
) -> 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)
|
||||
@ -253,7 +369,7 @@ fn brush_snapshot_from_points(
|
||||
actor_id: None,
|
||||
actor_kind: ActorKind::Brush,
|
||||
actor_name: None,
|
||||
name: Some("Brush".to_string()),
|
||||
name: Some(name),
|
||||
transform: Transform::from_translation(center),
|
||||
primitive: None,
|
||||
brush: Some(brush),
|
||||
@ -287,12 +403,115 @@ fn polygon_center(points: &[Vec3]) -> Vec3 {
|
||||
sum / points.len() as f32
|
||||
}
|
||||
|
||||
fn quantize_height(height: f32) -> f32 {
|
||||
((height.max(MIN_BRUSH_HEIGHT) * 10.0).round() / 10.0).max(MIN_BRUSH_HEIGHT)
|
||||
}
|
||||
|
||||
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"),
|
||||
count => format!("{count} points, Enter sets height"),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_brush_status(tool: &BrushToolState) -> String {
|
||||
if tool.phase == BrushToolPhase::Height {
|
||||
let part_count = decompose_floor_polygon_to_convex(&tool.vertices)
|
||||
.map(|parts| parts.len())
|
||||
.unwrap_or(1);
|
||||
return height_phase_status(part_count, tool.height);
|
||||
}
|
||||
let points = &tool.vertices;
|
||||
if points.len() < MIN_COMMIT_VERTICES {
|
||||
return format!("Draw Brush: {}", brush_tool_hint(points.len()));
|
||||
}
|
||||
match decompose_floor_polygon_to_convex(points) {
|
||||
Ok(parts) if parts.len() == 1 => {
|
||||
format!("Draw Brush: {} points, Enter creates brush", points.len())
|
||||
}
|
||||
Ok(parts) => format!(
|
||||
"Draw Brush: {} points, Enter creates {} convex brush parts",
|
||||
points.len(),
|
||||
parts.len()
|
||||
),
|
||||
Err(error) => format!("Draw Brush blocked: {}", floor_polygon_error_message(error)),
|
||||
}
|
||||
}
|
||||
|
||||
fn height_phase_status(part_count: usize, height: f32) -> String {
|
||||
if part_count == 1 {
|
||||
format!("Draw Brush height: {height:.1}m, Enter/LMB creates brush")
|
||||
} else {
|
||||
format!("Draw Brush height: {height:.1}m, Enter/LMB creates {part_count} convex parts")
|
||||
}
|
||||
}
|
||||
|
||||
fn brush_created_status(brush_count: usize, height: f32) -> String {
|
||||
if brush_count == 1 {
|
||||
format!("Brush created at {height:.1}m")
|
||||
} else {
|
||||
format!("Brush created as {brush_count} convex parts at {height:.1}m")
|
||||
}
|
||||
}
|
||||
|
||||
fn brush_committed_status(brush_count: usize, height: f32) -> String {
|
||||
if brush_count == 1 {
|
||||
format!("Committed brush.draw ({height:.1}m)")
|
||||
} else {
|
||||
format!("Committed brush.draw ({brush_count} parts, {height:.1}m)")
|
||||
}
|
||||
}
|
||||
|
||||
fn brush_preview_color(points: &[Vec3]) -> Color {
|
||||
if points.len() < MIN_COMMIT_VERTICES {
|
||||
return Color::srgb(0.2, 0.72, 1.0);
|
||||
}
|
||||
match decompose_floor_polygon_to_convex(points) {
|
||||
Ok(_) => Color::srgb(0.2, 0.72, 1.0),
|
||||
Err(_) => Color::srgb(1.0, 0.25, 0.2),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_decomposition_preview(parts: &[Vec<Vec3>], height: f32, gizmos: &mut Gizmos) {
|
||||
let color = Color::srgb(0.15, 1.0, 0.65);
|
||||
for part in parts {
|
||||
for window in part.windows(2) {
|
||||
gizmos.line(
|
||||
window[0] + Vec3::Y * 0.04,
|
||||
window[1] + Vec3::Y * 0.04,
|
||||
color,
|
||||
);
|
||||
gizmos.line(
|
||||
window[0] + Vec3::Y * height,
|
||||
window[1] + Vec3::Y * height,
|
||||
color,
|
||||
);
|
||||
}
|
||||
if let (Some(first), Some(last)) = (part.first(), part.last()) {
|
||||
gizmos.line(*last + Vec3::Y * 0.04, *first + Vec3::Y * 0.04, color);
|
||||
gizmos.line(*last + Vec3::Y * height, *first + Vec3::Y * height, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn floor_polygon_error_message(error: BrushPolygonError) -> String {
|
||||
match error {
|
||||
BrushPolygonError::TooFewVertices => {
|
||||
"need at least three points before creating a brush".into()
|
||||
}
|
||||
BrushPolygonError::NonFiniteVertex => "one or more points are not finite".into(),
|
||||
BrushPolygonError::DuplicateVertex => {
|
||||
"two points overlap; remove or move the duplicate point".into()
|
||||
}
|
||||
BrushPolygonError::ZeroArea => "polygon area is zero; points must enclose an area".into(),
|
||||
BrushPolygonError::SelfIntersecting => {
|
||||
"polygon edges cross; draw points around the perimeter in order".into()
|
||||
}
|
||||
BrushPolygonError::NonConvex => {
|
||||
"concave outline could not be decomposed into convex brush parts".into()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -316,7 +535,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn brush_snapshot_centers_polygon_points() {
|
||||
let snapshot = brush_snapshot_from_points(
|
||||
let snapshots = brush_snapshots_from_points(
|
||||
&[
|
||||
Vec3::new(0.0, 0.0, 0.0),
|
||||
Vec3::new(2.0, 0.0, 0.0),
|
||||
@ -326,6 +545,8 @@ mod tests {
|
||||
3.0,
|
||||
)
|
||||
.expect("valid brush");
|
||||
assert_eq!(snapshots.len(), 1);
|
||||
let snapshot = &snapshots[0];
|
||||
|
||||
assert_eq!(snapshot.actor_kind, ActorKind::Brush);
|
||||
assert_eq!(snapshot.transform.translation, Vec3::new(1.0, 0.0, 1.0));
|
||||
@ -333,8 +554,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brush_snapshot_rejects_non_convex_polygon() {
|
||||
let result = brush_snapshot_from_points(
|
||||
fn brush_snapshot_decomposes_non_convex_polygon() {
|
||||
let snapshots = brush_snapshots_from_points(
|
||||
&[
|
||||
Vec3::new(-1.0, 0.0, -1.0),
|
||||
Vec3::new(1.0, 0.0, -1.0),
|
||||
@ -343,8 +564,65 @@ mod tests {
|
||||
Vec3::new(-1.0, 0.0, 1.0),
|
||||
],
|
||||
2.0,
|
||||
)
|
||||
.expect("concave brush parts");
|
||||
|
||||
assert_eq!(snapshots.len(), 2);
|
||||
assert!(snapshots.iter().all(|snapshot| snapshot.brush.is_some()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brush_snapshot_reports_actionable_invalid_polygon_reason() {
|
||||
let result = brush_snapshots_from_points(
|
||||
&[
|
||||
Vec3::new(-1.0, 0.0, -1.0),
|
||||
Vec3::new(1.0, 0.0, 1.0),
|
||||
Vec3::new(1.0, 0.0, -1.0),
|
||||
Vec3::new(-1.0, 0.0, 1.0),
|
||||
],
|
||||
2.0,
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(
|
||||
result.unwrap_err(),
|
||||
"polygon edges cross; draw points around the perimeter in order"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_brush_status_explains_invalid_shape() {
|
||||
let mut tool = BrushToolState::default();
|
||||
tool.vertices = vec![
|
||||
Vec3::new(-1.0, 0.0, -1.0),
|
||||
Vec3::new(1.0, 0.0, -1.0),
|
||||
Vec3::new(-1.0, 0.0, 1.0),
|
||||
Vec3::new(1.0, 0.0, 1.0),
|
||||
];
|
||||
let status = draw_brush_status(&tool);
|
||||
|
||||
assert!(status.contains("polygon edges cross"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn height_phase_status_reports_height_and_part_count() {
|
||||
let mut tool = BrushToolState {
|
||||
active: true,
|
||||
phase: BrushToolPhase::Height,
|
||||
height: 4.2,
|
||||
vertices: vec![
|
||||
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),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
tool.height = quantize_height(tool.height);
|
||||
|
||||
let status = draw_brush_status(&tool);
|
||||
|
||||
assert!(status.contains("4.2m"));
|
||||
assert!(status.contains("2 convex parts"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -152,33 +152,196 @@ pub fn signed_area_xz(vertices: &[Vec3]) -> f32 {
|
||||
* 0.5
|
||||
}
|
||||
|
||||
pub fn validate_floor_polygon(vertices: &[Vec3]) -> Result<(), BrushPolygonError> {
|
||||
pub fn normalize_floor_polygon(vertices: &[Vec3]) -> Result<Vec<Vec3>, 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 {
|
||||
|
||||
let mut normalized = Vec::with_capacity(vertices.len());
|
||||
for vertex in vertices {
|
||||
if normalized.last().is_some_and(|previous: &Vec3| {
|
||||
previous.xz().distance_squared(vertex.xz()) <= EPSILON * EPSILON
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
normalized.push(*vertex);
|
||||
}
|
||||
if normalized.len() >= 2
|
||||
&& normalized[0]
|
||||
.xz()
|
||||
.distance_squared(normalized[normalized.len() - 1].xz())
|
||||
<= EPSILON * EPSILON
|
||||
{
|
||||
normalized.pop();
|
||||
}
|
||||
if normalized.len() < 3 {
|
||||
return Err(BrushPolygonError::TooFewVertices);
|
||||
}
|
||||
|
||||
for i in 0..normalized.len() {
|
||||
for j in (i + 1)..normalized.len() {
|
||||
if normalized[i].xz().distance_squared(normalized[j].xz()) <= EPSILON * EPSILON {
|
||||
return Err(BrushPolygonError::DuplicateVertex);
|
||||
}
|
||||
}
|
||||
}
|
||||
let area = signed_area_xz(vertices);
|
||||
if self_intersects_xz(vertices) {
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
pub fn validate_simple_floor_polygon(vertices: &[Vec3]) -> Result<(), BrushPolygonError> {
|
||||
let vertices = normalize_floor_polygon(vertices)?;
|
||||
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) {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_floor_polygon(vertices: &[Vec3]) -> Result<(), BrushPolygonError> {
|
||||
let vertices = normalize_floor_polygon(vertices)?;
|
||||
validate_simple_floor_polygon(&vertices)?;
|
||||
if !is_convex_xz(&vertices) {
|
||||
return Err(BrushPolygonError::NonConvex);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn decompose_floor_polygon_to_convex(
|
||||
vertices: &[Vec3],
|
||||
) -> Result<Vec<Vec<Vec3>>, BrushPolygonError> {
|
||||
let mut vertices = normalize_floor_polygon(vertices)?;
|
||||
validate_simple_floor_polygon(&vertices)?;
|
||||
if is_convex_xz(&vertices) {
|
||||
if signed_area_xz(&vertices) < 0.0 {
|
||||
vertices.reverse();
|
||||
}
|
||||
return Ok(vec![vertices]);
|
||||
}
|
||||
|
||||
if signed_area_xz(&vertices) < 0.0 {
|
||||
vertices.reverse();
|
||||
}
|
||||
|
||||
let mut remaining = vertices;
|
||||
let mut triangles = Vec::with_capacity(remaining.len().saturating_sub(2));
|
||||
while remaining.len() > 3 {
|
||||
let Some(ear_index) = find_ear_xz(&remaining) else {
|
||||
return Err(BrushPolygonError::NonConvex);
|
||||
};
|
||||
let prev = (ear_index + remaining.len() - 1) % remaining.len();
|
||||
let next = (ear_index + 1) % remaining.len();
|
||||
triangles.push(vec![remaining[prev], remaining[ear_index], remaining[next]]);
|
||||
remaining.remove(ear_index);
|
||||
}
|
||||
triangles.push(remaining);
|
||||
Ok(merge_convex_parts(triangles))
|
||||
}
|
||||
|
||||
fn merge_convex_parts(mut parts: Vec<Vec<Vec3>>) -> Vec<Vec<Vec3>> {
|
||||
let mut merged_any = true;
|
||||
while merged_any {
|
||||
merged_any = false;
|
||||
'pairs: for a in 0..parts.len() {
|
||||
for b in (a + 1)..parts.len() {
|
||||
let Some(merged) = merge_convex_pair(&parts[a], &parts[b]) else {
|
||||
continue;
|
||||
};
|
||||
parts[a] = merged;
|
||||
parts.remove(b);
|
||||
merged_any = true;
|
||||
break 'pairs;
|
||||
}
|
||||
}
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
fn merge_convex_pair(a: &[Vec3], b: &[Vec3]) -> Option<Vec<Vec3>> {
|
||||
let mut edges = Vec::<(Vec3, Vec3)>::with_capacity(a.len() + b.len());
|
||||
for part in [a, b] {
|
||||
for index in 0..part.len() {
|
||||
edges.push((part[index], part[(index + 1) % part.len()]));
|
||||
}
|
||||
}
|
||||
|
||||
let mut boundary = Vec::<(Vec3, Vec3)>::new();
|
||||
let mut removed_shared_edges = 0;
|
||||
for (index, edge) in edges.iter().enumerate() {
|
||||
let is_reversed_duplicate = edges.iter().enumerate().any(|(other_index, other)| {
|
||||
index != other_index && same_point_xz(edge.0, other.1) && same_point_xz(edge.1, other.0)
|
||||
});
|
||||
if is_reversed_duplicate {
|
||||
removed_shared_edges += 1;
|
||||
} else {
|
||||
boundary.push(*edge);
|
||||
}
|
||||
}
|
||||
if removed_shared_edges != 2 || boundary.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut polygon = Vec::with_capacity(boundary.len());
|
||||
let first = boundary.remove(0);
|
||||
polygon.push(first.0);
|
||||
let mut current = first.1;
|
||||
while !boundary.is_empty() {
|
||||
let next_index = boundary
|
||||
.iter()
|
||||
.position(|edge| same_point_xz(edge.0, current))?;
|
||||
let (_, next) = boundary.remove(next_index);
|
||||
if !same_point_xz(current, polygon[0]) {
|
||||
polygon.push(current);
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
if !same_point_xz(current, polygon[0]) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut polygon = normalize_floor_polygon(&polygon).ok()?;
|
||||
if signed_area_xz(&polygon) < 0.0 {
|
||||
polygon.reverse();
|
||||
}
|
||||
validate_floor_polygon(&polygon).ok()?;
|
||||
Some(polygon)
|
||||
}
|
||||
|
||||
fn find_ear_xz(vertices: &[Vec3]) -> Option<usize> {
|
||||
vertices.iter().enumerate().find_map(|(index, current)| {
|
||||
let prev_index = (index + vertices.len() - 1) % vertices.len();
|
||||
let next_index = (index + 1) % vertices.len();
|
||||
let prev = vertices[prev_index];
|
||||
let next = vertices[next_index];
|
||||
if orient_xz(prev, *current, next) <= EPSILON {
|
||||
return None;
|
||||
}
|
||||
let contains_other_vertex = vertices.iter().enumerate().any(|(candidate, point)| {
|
||||
candidate != prev_index
|
||||
&& candidate != index
|
||||
&& candidate != next_index
|
||||
&& point_in_triangle_xz(*point, prev, *current, next)
|
||||
});
|
||||
(!contains_other_vertex).then_some(index)
|
||||
})
|
||||
}
|
||||
|
||||
fn point_in_triangle_xz(point: Vec3, a: Vec3, b: Vec3, c: Vec3) -> bool {
|
||||
let ab = orient_xz(a, b, point);
|
||||
let bc = orient_xz(b, c, point);
|
||||
let ca = orient_xz(c, a, point);
|
||||
ab >= -EPSILON && bc >= -EPSILON && ca >= -EPSILON
|
||||
}
|
||||
|
||||
fn same_point_xz(a: Vec3, b: Vec3) -> bool {
|
||||
a.xz().distance_squared(b.xz()) <= EPSILON * EPSILON
|
||||
}
|
||||
|
||||
fn self_intersects_xz(vertices: &[Vec3]) -> bool {
|
||||
for a in 0..vertices.len() {
|
||||
let b = (a + 1) % vertices.len();
|
||||
@ -317,6 +480,57 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_adjacent_and_closing_duplicate_floor_points() {
|
||||
let normalized = normalize_floor_polygon(&[
|
||||
p(-1.0, -1.0),
|
||||
p(-1.0, -1.0),
|
||||
p(1.0, -1.0),
|
||||
p(1.0, 1.0),
|
||||
p(-1.0, 1.0),
|
||||
p(-1.0, -1.0),
|
||||
])
|
||||
.expect("normalized polygon");
|
||||
|
||||
assert_eq!(normalized.len(), 4);
|
||||
assert!(validate_floor_polygon(&normalized).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decomposes_concave_floor_polygon_into_convex_parts() {
|
||||
let parts = decompose_floor_polygon_to_convex(&[
|
||||
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),
|
||||
])
|
||||
.expect("convex decomposition");
|
||||
|
||||
assert_eq!(parts.len(), 2);
|
||||
for part in parts {
|
||||
assert!(validate_floor_polygon(&part).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merges_l_shaped_floor_polygon_to_two_convex_parts() {
|
||||
let parts = decompose_floor_polygon_to_convex(&[
|
||||
p(0.0, 0.0),
|
||||
p(4.0, 0.0),
|
||||
p(4.0, 1.0),
|
||||
p(1.0, 1.0),
|
||||
p(1.0, 4.0),
|
||||
p(0.0, 4.0),
|
||||
])
|
||||
.expect("convex decomposition");
|
||||
|
||||
assert_eq!(parts.len(), 2);
|
||||
for part in parts {
|
||||
assert!(validate_floor_polygon(&part).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_invalid_brush_faces() {
|
||||
let mut brush = BrushDesc::default();
|
||||
@ -364,4 +578,19 @@ mod tests {
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.message.contains("non-manifold")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_extruded_prism_winding() {
|
||||
let brush = BrushDesc::extruded_prism(
|
||||
&[p(-1.0, -1.0), p(1.0, -1.0), p(1.0, 1.0), p(-1.0, 1.0)],
|
||||
1.0,
|
||||
)
|
||||
.expect("valid prism");
|
||||
let report = validate_brush(&brush);
|
||||
assert!(
|
||||
report.is_valid(),
|
||||
"unexpected diagnostics: {:?}",
|
||||
report.diagnostics
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -438,11 +438,9 @@ impl BrushDesc {
|
||||
}
|
||||
|
||||
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 mut base = crate::brush_math::normalize_floor_polygon(base_vertices).ok()?;
|
||||
crate::brush_math::validate_floor_polygon(&base).ok()?;
|
||||
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();
|
||||
}
|
||||
@ -450,12 +448,12 @@ impl BrushDesc {
|
||||
.iter()
|
||||
.map(|vertex| *vertex + Vec3::Y * height)
|
||||
.collect();
|
||||
let mut bottom = base.clone();
|
||||
bottom.reverse();
|
||||
let mut top_face = top;
|
||||
top_face.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));
|
||||
faces.push(brush_face("face:+y", Vec3::Y, top_face));
|
||||
faces.push(brush_face("face:-y", Vec3::NEG_Y, base.clone()));
|
||||
for index in 0..base.len() {
|
||||
let next = (index + 1) % base.len();
|
||||
let a = base[index];
|
||||
|
||||
@ -44,7 +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, validates authored geometry in the inspector and Window → Brush Diagnostics, and hydrates active valid 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.
|
||||
- **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 locks the outline for height editing, mouse up/down adjusts height, and Enter/LMB creates additive prism brushes through history. Esc/right-click cancels. Simple concave outlines decompose into convex brush parts; self-intersections remain blocked.
|
||||
- **Brush edit modes** — with a brush selected, `1`/`2`/`3`/`4` enter vertex, edge, face, and clip element modes. Element modes show brush handles in the viewport, own LMB picking, support Shift multi-select, show a mode badge, and Esc returns to object mode. Vertex/edge/face selections can be dragged on the viewport floor plane and commit undoable `SetBrush` edits; clip mode is a preview/selection mode until CSG split support lands.
|
||||
- **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.
|
||||
|
||||
@ -12,7 +12,7 @@ Brushes are persisted blockout geometry stored as `ActorKind::Brush + BrushDesc`
|
||||
- The Brush card runs shared validation and reports invalid faces inline. Fatal geometry errors prevent hydration; warnings call out authoring issues that can still render. **Reset Cube Brush** is the MVP repair path.
|
||||
- Scene save runs the same fatal brush validation and blocks writing unrecoverable invalid brush geometry.
|
||||
- **Window → Brush Diagnostics** lists all brush actors, surfaces validation counts/messages, can select the affected brush, and provides an undoable **Reset Cube** repair for invalid brushes.
|
||||
- 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.
|
||||
- Draw Brush mode (`B`, toolbar pencil, or command `brush.draw`) creates additive prism brushes from snapped floor points. While active, the viewport shows phase-specific quick hints. In outline phase, LMB places points, Backspace removes the last point, and Enter locks the outline for height editing. In height phase, mouse up/down adjusts brush height, Enter or LMB commits through undo history, and Backspace returns to outline editing. Esc/right-click cancels without scene mutation. Simple concave outlines are decomposed into multiple convex brush parts with one undo entry, and the preview shows the generated part boundaries. Adjacent duplicate clicks and a closing duplicate point are merged before commit. Invalid outlines, including self-intersections, preview in red, report the reason in the status/operator text, log the failed Enter attempt, and leave the scene unchanged.
|
||||
|
||||
## Element Modes
|
||||
|
||||
@ -36,7 +36,7 @@ These operations validate selected brushes before previewing results and validat
|
||||
|
||||
## Current Limits
|
||||
|
||||
- Only convex authored faces are supported by the mesh builder.
|
||||
- Only convex authored faces are stored and hydrated by the mesh builder. Draw Brush can accept a simple concave floor outline by decomposing it into multiple convex brush actors.
|
||||
- Brush diagnostics currently cover face validity, plane normals, inverted face normals, finite vertices, duplicate vertices, degenerate area, open/non-manifold edges, and UV scale warnings.
|
||||
- Subtractive brush markers are stored but not automatically evaluated.
|
||||
- Arbitrary plane clipping, split-into-two output, and arbitrary-face CSG remain future roadmap work.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user