Add brush element drag edits
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
This commit is contained in:
parent
0bfc062ba7
commit
93ab6c2ea2
@ -2,9 +2,10 @@
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy_egui::{egui, EguiContexts};
|
||||
use shared::{BrushDesc, BrushFaceDesc, ComponentInstanceId};
|
||||
use shared::{BrushDesc, BrushFaceDesc, BrushPlaneDesc, ComponentInstanceId};
|
||||
|
||||
use crate::camera::EditorCamera;
|
||||
use crate::history::{push_command, EditorCommand};
|
||||
use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus};
|
||||
use crate::scene_io::SceneIo;
|
||||
use crate::selection::ViewportClick;
|
||||
@ -94,11 +95,13 @@ impl Plugin for BrushEditPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<BrushEditMode>()
|
||||
.init_resource::<BrushElementSelection>()
|
||||
.init_resource::<BrushElementDrag>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
brush_edit_hotkeys,
|
||||
brush_element_pick,
|
||||
brush_element_drag,
|
||||
draw_brush_edit_overlays,
|
||||
)
|
||||
.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)]
|
||||
fn brush_edit_hotkeys(
|
||||
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(
|
||||
mode: Res<BrushEditMode>,
|
||||
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(
|
||||
brush: &BrushDesc,
|
||||
transform: &GlobalTransform,
|
||||
@ -513,6 +738,7 @@ fn set_brush_edit_status(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use shared::BrushDesc;
|
||||
|
||||
#[test]
|
||||
fn point_inside_convex_polygon_is_detected() {
|
||||
@ -529,4 +755,32 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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).
|
||||
|
||||
@ -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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user