Add brush element drag edits
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run

This commit is contained in:
Rbanh 2026-06-06 06:01:56 -04:00
parent 0bfc062ba7
commit 93ab6c2ea2
3 changed files with 257 additions and 3 deletions

View File

@ -2,9 +2,10 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy_egui::{egui, EguiContexts}; use bevy_egui::{egui, EguiContexts};
use shared::{BrushDesc, BrushFaceDesc, ComponentInstanceId}; use shared::{BrushDesc, BrushFaceDesc, BrushPlaneDesc, ComponentInstanceId};
use crate::camera::EditorCamera; use crate::camera::EditorCamera;
use crate::history::{push_command, EditorCommand};
use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus}; use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus};
use crate::scene_io::SceneIo; use crate::scene_io::SceneIo;
use crate::selection::ViewportClick; use crate::selection::ViewportClick;
@ -94,11 +95,13 @@ impl Plugin for BrushEditPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<BrushEditMode>() app.init_resource::<BrushEditMode>()
.init_resource::<BrushElementSelection>() .init_resource::<BrushElementSelection>()
.init_resource::<BrushElementDrag>()
.add_systems( .add_systems(
Update, Update,
( (
brush_edit_hotkeys, brush_edit_hotkeys,
brush_element_pick, brush_element_pick,
brush_element_drag,
draw_brush_edit_overlays, draw_brush_edit_overlays,
) )
.chain() .chain()
@ -107,6 +110,14 @@ impl Plugin for BrushEditPlugin {
} }
} }
#[derive(Resource, Default, Debug, Clone)]
struct BrushElementDrag {
brush: Option<Entity>,
old: Option<BrushDesc>,
last_floor: Option<Vec3>,
changed: bool,
}
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn brush_edit_hotkeys( fn brush_edit_hotkeys(
mut mode: ResMut<BrushEditMode>, mut mode: ResMut<BrushEditMode>,
@ -218,6 +229,105 @@ fn brush_element_pick(
} }
} }
#[allow(clippy::too_many_arguments)]
fn brush_element_drag(
mut commands: Commands,
mode: Res<BrushEditMode>,
selection: Res<BrushElementSelection>,
mut drag: ResMut<BrushElementDrag>,
mut active_operator: ResMut<ActiveOperator>,
buttons: Res<ButtonInput<MouseButton>>,
ui_state: Res<UiState>,
cameras: Query<(&Camera, &GlobalTransform), With<EditorCamera>>,
mut brushes: Query<(&mut BrushDesc, &GlobalTransform)>,
) {
if !mode.is_element_mode() || matches!(*mode, BrushEditMode::Clip) {
clear_drag(&mut drag);
return;
}
let Some(brush_entity) = selection.brush else {
clear_drag(&mut drag);
return;
};
if selection.elements.is_empty() {
clear_drag(&mut drag);
return;
}
let Some(pointer_pos) = ui_state.viewport_pointer_pos else {
return;
};
let Some(current_floor) = pointer_floor_position(&cameras, pointer_pos, ui_state.viewport_rect)
else {
return;
};
if buttons.just_pressed(MouseButton::Left) && drag.brush.is_none() {
if let Ok((brush, _)) = brushes.get(brush_entity) {
drag.brush = Some(brush_entity);
drag.old = Some(brush.clone());
drag.last_floor = Some(current_floor);
drag.changed = false;
}
return;
}
if buttons.pressed(MouseButton::Left) && drag.brush == Some(brush_entity) {
let Some(last_floor) = drag.last_floor else {
drag.last_floor = Some(current_floor);
return;
};
let world_delta = current_floor - last_floor;
if world_delta.length_squared() <= 0.000001 {
return;
}
if let Ok((mut brush, transform)) = brushes.get_mut(brush_entity) {
let local_delta = transform.affine().inverse().transform_vector3(world_delta);
if move_selected_elements(&mut brush, &selection.elements, local_delta) {
drag.changed = true;
drag.last_floor = Some(current_floor);
set_brush_edit_status(
&mut active_operator,
OperatorPhase::Preview,
format!("Dragging {} element(s)", selection.elements.len()),
);
}
}
}
if buttons.just_released(MouseButton::Left) {
if let (Some(brush), Some(old)) = (drag.brush, drag.old.take()) {
if drag.changed {
if let Ok((brush_desc, _)) = brushes.get(brush) {
let new = brush_desc.clone();
commands.queue(move |world: &mut World| {
push_command(
world,
EditorCommand::SetBrush {
entity: brush,
old: Some(old),
new,
},
);
});
set_brush_edit_status(
&mut active_operator,
OperatorPhase::Committed,
"Committed brush element edit",
);
}
}
}
clear_drag(&mut drag);
}
}
fn clear_drag(drag: &mut BrushElementDrag) {
drag.brush = None;
drag.old = None;
drag.last_floor = None;
drag.changed = false;
}
fn draw_brush_edit_overlays( fn draw_brush_edit_overlays(
mode: Res<BrushEditMode>, mode: Res<BrushEditMode>,
selection: Res<BrushElementSelection>, selection: Res<BrushElementSelection>,
@ -349,6 +459,121 @@ fn pick_element(
} }
} }
fn pointer_floor_position(
cameras: &Query<(&Camera, &GlobalTransform), With<EditorCamera>>,
pointer_pos: egui::Pos2,
scene_rect: egui::Rect,
) -> Option<Vec3> {
let ray = viewport_ray(cameras, 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 move_selected_elements(
brush: &mut BrushDesc,
selected: &[BrushElementKey],
delta: Vec3,
) -> bool {
if delta.length_squared() <= 0.000001 {
return false;
}
let anchors = selected_anchor_positions(brush, selected);
if anchors.is_empty() {
return false;
}
let mut changed = false;
for face in &mut brush.faces {
for vertex in &mut face.vertices {
if anchors
.iter()
.any(|anchor| vertex.distance_squared(*anchor) <= 0.0001)
{
*vertex += delta;
changed = true;
}
}
}
if changed {
recompute_face_planes(brush);
}
changed
}
fn selected_anchor_positions(brush: &BrushDesc, selected: &[BrushElementKey]) -> Vec<Vec3> {
let mut anchors = Vec::new();
for element in selected {
match element {
BrushElementKey::Vertex { face, index } => {
if let Some(face_desc) = brush.faces.iter().find(|candidate| &candidate.id == face)
{
if let Some(vertex) = face_desc.vertices.get(*index) {
push_unique_anchor(&mut anchors, *vertex);
}
}
}
BrushElementKey::Edge { face, index } => {
if let Some(face_desc) = brush.faces.iter().find(|candidate| &candidate.id == face)
{
if !face_desc.vertices.is_empty() {
let next = (*index + 1) % face_desc.vertices.len();
if let Some(vertex) = face_desc.vertices.get(*index) {
push_unique_anchor(&mut anchors, *vertex);
}
if let Some(vertex) = face_desc.vertices.get(next) {
push_unique_anchor(&mut anchors, *vertex);
}
}
}
}
BrushElementKey::Face { face } => {
if let Some(face_desc) = brush.faces.iter().find(|candidate| &candidate.id == face)
{
for vertex in &face_desc.vertices {
push_unique_anchor(&mut anchors, *vertex);
}
}
}
}
}
anchors
}
fn push_unique_anchor(anchors: &mut Vec<Vec3>, vertex: Vec3) {
if !anchors
.iter()
.any(|existing| existing.distance_squared(vertex) <= 0.0001)
{
anchors.push(vertex);
}
}
fn recompute_face_planes(brush: &mut BrushDesc) {
for face in &mut brush.faces {
let Some(plane) = plane_from_vertices(&face.vertices, face.plane.normal) else {
continue;
};
face.plane = plane;
}
}
fn plane_from_vertices(vertices: &[Vec3], fallback_normal: Vec3) -> Option<BrushPlaneDesc> {
if vertices.len() < 3 {
return None;
}
let normal = (vertices[1] - vertices[0])
.cross(vertices[2] - vertices[0])
.try_normalize()
.or_else(|| fallback_normal.try_normalize())?;
Some(BrushPlaneDesc {
normal,
distance: normal.dot(vertices[0]),
})
}
fn pick_vertex( fn pick_vertex(
brush: &BrushDesc, brush: &BrushDesc,
transform: &GlobalTransform, transform: &GlobalTransform,
@ -513,6 +738,7 @@ fn set_brush_edit_status(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use shared::BrushDesc;
#[test] #[test]
fn point_inside_convex_polygon_is_detected() { fn point_inside_convex_polygon_is_detected() {
@ -529,4 +755,32 @@ mod tests {
Vec3::Y Vec3::Y
)); ));
} }
#[test]
fn moving_selected_vertex_moves_matching_welded_corners() {
let mut brush = BrushDesc::default();
let selected_vertex = brush.faces[0].vertices[0];
let before = brush
.faces
.iter()
.flat_map(|face| face.vertices.iter())
.filter(|vertex| vertex.distance_squared(selected_vertex) <= 0.0001)
.count();
let face = brush.faces[0].id.clone();
let changed = move_selected_elements(
&mut brush,
&[BrushElementKey::Vertex { face, index: 0 }],
Vec3::new(0.5, 0.0, 0.0),
);
let after = brush
.faces
.iter()
.flat_map(|face| face.vertices.iter())
.filter(|vertex| vertex.distance_squared(selected_vertex + Vec3::X * 0.5) <= 0.0001)
.count();
assert!(changed);
assert_eq!(before, after);
}
} }

View File

@ -45,7 +45,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
- **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. - **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.
- **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. Geometry mutation is staged for the follow-up edit operation slice. - **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. - **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).

View File

@ -15,7 +15,7 @@ Brushes are persisted blockout geometry stored as `ActorKind::Brush + BrushDesc`
With a brush actor selected, `1`/`2`/`3`/`4` enter vertex, edge, face, and clip modes. Element modes show viewport overlays for the selected brush, route LMB to element picking, support Shift+LMB multi-select, and display a `Brush: <mode>` badge. `Esc` returns to object mode. With a brush actor selected, `1`/`2`/`3`/`4` enter vertex, edge, face, and clip modes. Element modes show viewport overlays for the selected brush, route LMB to element picking, support Shift+LMB multi-select, and display a `Brush: <mode>` badge. `Esc` returns to object mode.
The current slice selects elements and visualizes handles; mutating vertices/faces and committing drag edits through history is the next implementation layer. Vertex, edge, and face selections can be dragged on the viewport floor plane. The editor moves matching duplicated corner vertices across all brush faces so simple cube/prism brushes stay welded, recomputes face planes, and commits one undoable brush edit when the drag releases. Clip mode currently provides element visualization/selection only; split application is future CSG work.
## Current Limits ## Current Limits