diff --git a/crates/editor/src/viewport/brush_edit.rs b/crates/editor/src/viewport/brush_edit.rs index 7f378e0..d396d39 100644 --- a/crates/editor/src/viewport/brush_edit.rs +++ b/crates/editor/src/viewport/brush_edit.rs @@ -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::() .init_resource::() + .init_resource::() .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, + old: Option, + last_floor: Option, + changed: bool, +} + #[allow(clippy::too_many_arguments)] fn brush_edit_hotkeys( mut mode: ResMut, @@ -218,6 +229,105 @@ fn brush_element_pick( } } +#[allow(clippy::too_many_arguments)] +fn brush_element_drag( + mut commands: Commands, + mode: Res, + selection: Res, + mut drag: ResMut, + mut active_operator: ResMut, + buttons: Res>, + ui_state: Res, + cameras: Query<(&Camera, &GlobalTransform), With>, + 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, selection: Res, @@ -349,6 +459,121 @@ fn pick_element( } } +fn pointer_floor_position( + cameras: &Query<(&Camera, &GlobalTransform), With>, + pointer_pos: egui::Pos2, + scene_rect: egui::Rect, +) -> Option { + 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 { + 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, 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 { + 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); + } } diff --git a/docs/editor/README.md b/docs/editor/README.md index 353ef1e..4cf8d65 100644 --- a/docs/editor/README.md +++ b/docs/editor/README.md @@ -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). diff --git a/docs/editor/brushes.md b/docs/editor/brushes.md index 9bd89c8..ecf4900 100644 --- a/docs/editor/brushes.md +++ b/docs/editor/brushes.md @@ -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: ` 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