Blacksite/crates/editor/src/viewport/brush_edit.rs
Rbanh 93ab6c2ea2
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Add brush element drag edits
2026-06-06 06:01:56 -04:00

787 lines
24 KiB
Rust

//! Brush element edit modes and viewport element picking.
use bevy::prelude::*;
use bevy_egui::{egui, EguiContexts};
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;
use crate::state::scene_tools_active;
use crate::ui::{viewport_keyboard_shortcuts_active, UiState};
use crate::viewport::{scene_view_ray, ViewportDisplayMode};
#[derive(Resource, Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum BrushEditMode {
#[default]
Object,
Vertex,
Edge,
Face,
Clip,
}
impl BrushEditMode {
pub fn label(self) -> &'static str {
match self {
Self::Object => "Object",
Self::Vertex => "Vertex",
Self::Edge => "Edge",
Self::Face => "Face",
Self::Clip => "Clip",
}
}
pub fn is_element_mode(self) -> bool {
self != Self::Object
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum BrushElementKey {
Vertex {
face: ComponentInstanceId,
index: usize,
},
Edge {
face: ComponentInstanceId,
index: usize,
},
Face {
face: ComponentInstanceId,
},
}
#[derive(Resource, Default, Debug, Clone)]
pub struct BrushElementSelection {
pub brush: Option<Entity>,
pub elements: Vec<BrushElementKey>,
}
impl BrushElementSelection {
fn replace(&mut self, brush: Entity, element: BrushElementKey) {
self.brush = Some(brush);
self.elements.clear();
self.elements.push(element);
}
fn toggle(&mut self, brush: Entity, element: BrushElementKey) {
if self.brush != Some(brush) {
self.replace(brush, element);
return;
}
if let Some(index) = self
.elements
.iter()
.position(|existing| existing == &element)
{
self.elements.remove(index);
} else {
self.elements.push(element);
}
}
pub fn clear(&mut self) {
self.brush = None;
self.elements.clear();
}
}
pub struct BrushEditPlugin;
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()
.run_if(scene_tools_active),
);
}
}
#[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>,
mut element_selection: ResMut<BrushElementSelection>,
mut active_operator: ResMut<ActiveOperator>,
mut scene_io: ResMut<SceneIo>,
keys: Res<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>,
mut contexts: EguiContexts,
ui_state: Res<UiState>,
display: Res<ViewportDisplayMode>,
brushes: Query<(), With<BrushDesc>>,
) -> Result {
if display.clean_game_view {
return Ok(());
}
let ctx = contexts.ctx_mut()?;
if !viewport_keyboard_shortcuts_active(&ui_state, ctx, &buttons) || ctx.wants_keyboard_input() {
return Ok(());
}
if keys.just_pressed(KeyCode::Escape) && mode.is_element_mode() {
*mode = BrushEditMode::Object;
element_selection.clear();
set_brush_edit_status(&mut active_operator, OperatorPhase::Canceled, "Object mode");
scene_io.status = "Brush edit: Object mode".to_string();
return Ok(());
}
let requested = if keys.just_pressed(KeyCode::Digit1) {
Some(BrushEditMode::Vertex)
} else if keys.just_pressed(KeyCode::Digit2) {
Some(BrushEditMode::Edge)
} else if keys.just_pressed(KeyCode::Digit3) {
Some(BrushEditMode::Face)
} else if keys.just_pressed(KeyCode::Digit4) {
Some(BrushEditMode::Clip)
} else {
None
};
let Some(requested) = requested else {
return Ok(());
};
let Some(selected_brush) = selected_brush_entity(&ui_state, &brushes) else {
scene_io.status = "Select a brush before entering brush edit mode".to_string();
set_brush_edit_status(
&mut active_operator,
OperatorPhase::Blocked,
"Select a brush before editing elements",
);
return Ok(());
};
*mode = requested;
element_selection.brush = Some(selected_brush);
element_selection.elements.clear();
scene_io.status = format!("Brush edit: {} mode", mode.label());
set_brush_edit_status(
&mut active_operator,
OperatorPhase::Preview,
format!("{} mode", mode.label()),
);
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn brush_element_pick(
mode: Res<BrushEditMode>,
mut viewport_click: ResMut<ViewportClick>,
mut element_selection: ResMut<BrushElementSelection>,
keys: Res<ButtonInput<KeyCode>>,
ui_state: Res<UiState>,
cameras: Query<(&Camera, &GlobalTransform), With<EditorCamera>>,
brushes: Query<(Entity, &BrushDesc, &GlobalTransform)>,
) {
if !mode.is_element_mode() {
return;
}
let Some(pointer_pos) = viewport_click.0.take() else {
return;
};
let Some(ray) = viewport_ray(&cameras, pointer_pos, ui_state.viewport_rect) else {
return;
};
let brush_filter = element_selection.brush;
let mut best: Option<(Entity, BrushElementKey, f32)> = None;
for (entity, brush, transform) in &brushes {
if brush_filter.is_some_and(|selected| selected != entity) {
continue;
}
if let Some((element, distance)) = pick_element(*mode, brush, transform, ray) {
if best
.as_ref()
.is_none_or(|(_, _, best_distance)| distance < *best_distance)
{
best = Some((entity, element, distance));
}
}
}
let Some((brush, element, _)) = best else {
if !keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]) {
element_selection.clear();
}
return;
};
if keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]) {
element_selection.toggle(brush, element);
} else {
element_selection.replace(brush, element);
}
}
#[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>,
ui_state: Res<UiState>,
brushes: Query<(Entity, &BrushDesc, &GlobalTransform)>,
mut gizmos: Gizmos,
) {
if !mode.is_element_mode() {
return;
}
let selected_brushes: Vec<Entity> = ui_state.selected_entities.iter().collect();
for (entity, brush, transform) in &brushes {
if !selected_brushes.contains(&entity) {
continue;
}
let active = selection.brush == Some(entity);
draw_brush_elements(
*mode,
brush,
transform,
&selection.elements,
active,
&mut gizmos,
);
}
}
fn draw_brush_elements(
mode: BrushEditMode,
brush: &BrushDesc,
transform: &GlobalTransform,
selected: &[BrushElementKey],
active: bool,
gizmos: &mut Gizmos,
) {
let base_color = match mode {
BrushEditMode::Vertex => Color::srgb(0.2, 0.72, 1.0),
BrushEditMode::Edge => Color::srgb(1.0, 0.68, 0.24),
BrushEditMode::Face => Color::srgb(0.24, 1.0, 0.56),
BrushEditMode::Clip => Color::srgb(0.92, 0.36, 1.0),
BrushEditMode::Object => Color::WHITE,
};
let selected_color = Color::srgb(1.0, 0.95, 0.2);
for face in &brush.faces {
let vertices = world_vertices(face, transform);
if vertices.is_empty() {
continue;
}
let face_center = average_point(&vertices);
let normal = transform
.affine()
.transform_vector3(face.plane.normal)
.normalize_or_zero();
if matches!(mode, BrushEditMode::Face | BrushEditMode::Clip) {
let key = BrushElementKey::Face {
face: face.id.clone(),
};
let color = if active && selected.contains(&key) {
selected_color
} else {
base_color
};
gizmos.sphere(face_center, 0.08, color);
gizmos.line(face_center, face_center + normal * 0.55, color);
}
for index in 0..vertices.len() {
let next = (index + 1) % vertices.len();
if matches!(mode, BrushEditMode::Vertex) {
let key = BrushElementKey::Vertex {
face: face.id.clone(),
index,
};
let color = if active && selected.contains(&key) {
selected_color
} else {
base_color
};
gizmos.sphere(vertices[index], 0.06, color);
}
if matches!(mode, BrushEditMode::Edge | BrushEditMode::Clip) {
let key = BrushElementKey::Edge {
face: face.id.clone(),
index,
};
let color = if active && selected.contains(&key) {
selected_color
} else {
base_color
};
gizmos.line(vertices[index], vertices[next], color);
}
}
}
}
fn selected_brush_entity(
ui_state: &UiState,
brushes: &Query<(), With<BrushDesc>>,
) -> Option<Entity> {
ui_state
.selected_entities
.iter()
.find(|entity| brushes.contains(*entity))
}
fn viewport_ray(
cameras: &Query<(&Camera, &GlobalTransform), With<EditorCamera>>,
pointer_pos: egui::Pos2,
scene_rect: egui::Rect,
) -> Option<Ray3d> {
let (camera, transform) = cameras
.iter()
.find(|(camera, _)| camera.is_active)
.or_else(|| cameras.iter().next())?;
scene_view_ray(camera, transform, pointer_pos, scene_rect)
}
fn pick_element(
mode: BrushEditMode,
brush: &BrushDesc,
transform: &GlobalTransform,
ray: Ray3d,
) -> Option<(BrushElementKey, f32)> {
match mode {
BrushEditMode::Vertex => pick_vertex(brush, transform, ray),
BrushEditMode::Edge => pick_edge(brush, transform, ray),
BrushEditMode::Face | BrushEditMode::Clip => pick_face(brush, transform, ray),
BrushEditMode::Object => None,
}
}
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,
ray: Ray3d,
) -> Option<(BrushElementKey, f32)> {
let mut best = None;
for face in &brush.faces {
for (index, vertex) in world_vertices(face, transform).iter().enumerate() {
let distance = distance_ray_point(ray, *vertex);
if distance < 0.25 && best.as_ref().is_none_or(|(_, best)| distance < *best) {
best = Some((
BrushElementKey::Vertex {
face: face.id.clone(),
index,
},
distance,
));
}
}
}
best
}
fn pick_edge(
brush: &BrushDesc,
transform: &GlobalTransform,
ray: Ray3d,
) -> Option<(BrushElementKey, f32)> {
let mut best = None;
for face in &brush.faces {
let vertices = world_vertices(face, transform);
for index in 0..vertices.len() {
let next = (index + 1) % vertices.len();
let distance = distance_ray_segment(ray, vertices[index], vertices[next]);
if distance < 0.2 && best.as_ref().is_none_or(|(_, best)| distance < *best) {
best = Some((
BrushElementKey::Edge {
face: face.id.clone(),
index,
},
distance,
));
}
}
}
best
}
fn pick_face(
brush: &BrushDesc,
transform: &GlobalTransform,
ray: Ray3d,
) -> Option<(BrushElementKey, f32)> {
let mut best = None;
for face in &brush.faces {
let vertices = world_vertices(face, transform);
if vertices.len() < 3 {
continue;
}
let normal = transform
.affine()
.transform_vector3(face.plane.normal)
.normalize_or_zero();
let denom = normal.dot(ray.direction.as_vec3());
if denom.abs() < 0.0001 {
continue;
}
let distance = normal.dot(vertices[0] - ray.origin) / denom;
if distance < 0.0 {
continue;
}
let hit = ray.origin + ray.direction.as_vec3() * distance;
if point_in_convex_polygon(hit, &vertices, normal)
&& best.as_ref().is_none_or(|(_, best)| distance < *best)
{
best = Some((
BrushElementKey::Face {
face: face.id.clone(),
},
distance,
));
}
}
best
}
fn world_vertices(face: &BrushFaceDesc, transform: &GlobalTransform) -> Vec<Vec3> {
face.vertices
.iter()
.map(|vertex| transform.transform_point(*vertex))
.collect()
}
fn average_point(points: &[Vec3]) -> Vec3 {
points.iter().copied().sum::<Vec3>() / points.len() as f32
}
fn point_in_convex_polygon(point: Vec3, vertices: &[Vec3], normal: Vec3) -> bool {
let mut expected_sign = 0.0f32;
for index in 0..vertices.len() {
let next = (index + 1) % vertices.len();
let edge = vertices[next] - vertices[index];
let to_point = point - vertices[index];
let side = edge.cross(to_point).dot(normal);
if side.abs() <= 0.0001 {
continue;
}
if expected_sign == 0.0 {
expected_sign = side.signum();
} else if side.signum() != expected_sign {
return false;
}
}
true
}
fn distance_ray_point(ray: Ray3d, point: Vec3) -> f32 {
let to_point = point - ray.origin;
let t = to_point.dot(ray.direction.as_vec3()).max(0.0);
(ray.origin + ray.direction.as_vec3() * t - point).length()
}
fn distance_ray_segment(ray: Ray3d, a: Vec3, b: Vec3) -> f32 {
let ray_dir = ray.direction.as_vec3();
let segment = b - a;
let w0 = ray.origin - a;
let a_dot = ray_dir.dot(ray_dir);
let b_dot = ray_dir.dot(segment);
let c_dot = segment.dot(segment);
let d_dot = ray_dir.dot(w0);
let e_dot = segment.dot(w0);
let denom = a_dot * c_dot - b_dot * b_dot;
let ray_t = if denom.abs() > 0.0001 {
((b_dot * e_dot - c_dot * d_dot) / denom).max(0.0)
} else {
0.0
};
let segment_t = if c_dot > 0.0001 {
((a_dot * e_dot - b_dot * d_dot) / denom.max(0.0001)).clamp(0.0, 1.0)
} else {
0.0
};
let ray_point = ray.origin + ray_dir * ray_t;
let segment_point = a + segment * segment_t;
ray_point.distance(segment_point)
}
fn set_brush_edit_status(
active_operator: &mut ActiveOperator,
phase: OperatorPhase,
hint: impl Into<String>,
) {
active_operator.status = Some(OperatorStatus {
id: "brush.edit_mode".to_string(),
label: "Brush Edit".to_string(),
phase,
hint: hint.into(),
warnings: Vec::new(),
});
}
#[cfg(test)]
mod tests {
use super::*;
use shared::BrushDesc;
#[test]
fn point_inside_convex_polygon_is_detected() {
let 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),
];
assert!(point_in_convex_polygon(Vec3::ZERO, &vertices, Vec3::Y));
assert!(!point_in_convex_polygon(
Vec3::new(2.0, 0.0, 0.0),
&vertices,
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);
}
}