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::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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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).
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user