//! Brush element edit modes and viewport element picking. use bevy::prelude::*; use bevy_egui::{egui, EguiContexts}; use shared::{ brush_math::{validate_brush, BrushDiagnosticSeverity}, BrushDesc, BrushFaceDesc, BrushPlaneDesc, ComponentInstanceId, }; use crate::camera::EditorCamera; use crate::history::{push_command, set_brush_transform_with_history, EditorCommand}; use crate::infra::EditorOnly; 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}; use transform_gizmo_bevy::prelude::GizmoTarget; #[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, pub elements: Vec, } 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::() .init_resource::() .init_resource::() .add_systems( Update, ( brush_edit_hotkeys, brush_element_pick, sync_brush_element_gizmo, brush_clip_commit, draw_brush_edit_overlays, ) .chain() .run_if(scene_tools_active), ) .add_systems(Last, apply_brush_element_gizmo.run_if(scene_tools_active)); } } #[derive(Component, Debug, Clone, Copy)] pub struct BrushElementGizmo; #[derive(Resource, Default, Debug, Clone)] struct BrushElementGizmoState { entity: Option, brush: Option, old_brush: Option, old_gizmo: Option, last_gizmo: Option, changed: bool, } #[allow(clippy::too_many_arguments)] fn brush_edit_hotkeys( mut mode: ResMut, mut element_selection: ResMut, mut active_operator: ResMut, mut scene_io: ResMut, keys: Res>, buttons: Res>, mut contexts: EguiContexts, ui_state: Res, display: Res, brushes: Query<(), With>, ) -> Result { if display.clean_game_view { return Ok(()); } let ctx = contexts.ctx_mut()?; if !viewport_keyboard_shortcuts_active(&ui_state, ctx, &buttons) || ctx.egui_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, mut viewport_click: ResMut, mut element_selection: ResMut, keys: Res>, ui_state: Res, cameras: Query<(&Camera, &GlobalTransform), With>, 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); } } fn sync_brush_element_gizmo( mut commands: Commands, mode: Res, selection: Res, mut state: ResMut, mut brushes: Query<(&mut BrushDesc, &GlobalTransform)>, mut gizmos: Query<&mut Transform, With>, ) { if !mode.is_element_mode() || matches!(*mode, BrushEditMode::Clip) { if let Some((brush_entity, old_brush)) = take_brush_edit_rollback(&mut state) { if let Ok((mut brush, _)) = brushes.get_mut(brush_entity) { *brush = old_brush; } } clear_brush_element_gizmo(&mut commands, &mut state); return; } let Some(brush_entity) = selection.brush else { clear_brush_element_gizmo(&mut commands, &mut state); return; }; if selection.elements.is_empty() { clear_brush_element_gizmo(&mut commands, &mut state); return; } let Ok((brush, brush_transform)) = brushes.get(brush_entity) else { clear_brush_element_gizmo(&mut commands, &mut state); return; }; let Some(pivot) = selected_element_world_pivot(brush, brush_transform, &selection.elements) else { clear_brush_element_gizmo(&mut commands, &mut state); return; }; let rotation = brush_transform.to_scale_rotation_translation().1; let desired = Transform::from_translation(pivot).with_rotation(rotation); let needs_new_entity = state.brush != Some(brush_entity) || state .entity .is_none_or(|entity| gizmos.get(entity).is_err()); if needs_new_entity { clear_brush_element_gizmo(&mut commands, &mut state); let entity = commands .spawn(( BrushElementGizmo, EditorOnly, GizmoTarget::default(), desired, GlobalTransform::default(), Visibility::Hidden, InheritedVisibility::default(), )) .id(); state.entity = Some(entity); state.brush = Some(brush_entity); state.old_brush = None; state.old_gizmo = Some(desired); state.last_gizmo = Some(desired); state.changed = false; return; } let Some(entity) = state.entity else { return; }; let Ok(mut transform) = gizmos.get_mut(entity) else { clear_brush_element_gizmo(&mut commands, &mut state); return; }; if state.old_brush.is_none() { let current = *transform; if !gizmo_transform_is_active(&state, current) { *transform = desired; state.old_gizmo = Some(desired); state.last_gizmo = Some(desired); } } } #[allow(clippy::too_many_arguments)] fn apply_brush_element_gizmo( mut commands: Commands, mode: Res, selection: Res, mut state: ResMut, mut active_operator: ResMut, mut brushes: Query<(&mut BrushDesc, &GlobalTransform)>, gizmos: Query<(&Transform, &GizmoTarget), With>, ) { if !mode.is_element_mode() || matches!(*mode, BrushEditMode::Clip) { clear_brush_element_gizmo(&mut commands, &mut state); return; } let (Some(gizmo_entity), Some(brush_entity)) = (state.entity, selection.brush) else { return; }; if selection.elements.is_empty() || state.brush != Some(brush_entity) { clear_brush_element_gizmo(&mut commands, &mut state); return; } let Ok((gizmo_transform, target)) = gizmos.get(gizmo_entity) else { clear_brush_element_gizmo(&mut commands, &mut state); return; }; if target.is_active() { let Ok((mut brush, brush_transform)) = brushes.get_mut(brush_entity) else { clear_brush_element_gizmo(&mut commands, &mut state); return; }; if state.old_brush.is_none() { state.old_brush = Some(brush.clone()); state.old_gizmo = Some(*gizmo_transform); state.last_gizmo = Some(*gizmo_transform); state.changed = false; return; } let Some(last_gizmo) = state.last_gizmo else { state.last_gizmo = Some(*gizmo_transform); return; }; if !transforms_nearly_equal(last_gizmo, *gizmo_transform) && transform_selected_elements_by_gizmo( &mut brush, &selection.elements, brush_transform, last_gizmo, *gizmo_transform, ) { state.changed = true; state.last_gizmo = Some(*gizmo_transform); set_brush_edit_status( &mut active_operator, OperatorPhase::Preview, format!( "Gizmo editing {} brush element(s)", selection.elements.len() ), ); } return; } if let (Some(old), Some(_)) = (state.old_brush.take(), state.old_gizmo.take()) { if state.changed { let Ok((mut brush, _)) = brushes.get_mut(brush_entity) else { clear_brush_element_gizmo(&mut commands, &mut state); return; }; let new = brush.clone(); let validation = validate_brush(&new); if validation.is_valid() { commands.queue(move |world: &mut World| { push_command( world, EditorCommand::SetBrush { entity: brush_entity, old: Some(old), new, }, ); }); set_brush_edit_status( &mut active_operator, OperatorPhase::Committed, "Committed brush element gizmo edit", ); } else { *brush = old; let message = validation .diagnostics .iter() .find(|diagnostic| diagnostic.severity == BrushDiagnosticSeverity::Error) .map(|diagnostic| diagnostic.message.as_str()) .unwrap_or("Brush edit produced invalid geometry"); set_brush_edit_status( &mut active_operator, OperatorPhase::Blocked, format!("Rejected brush edit: {message}"), ); } } } state.last_gizmo = Some(*gizmo_transform); state.changed = false; } fn take_brush_edit_rollback(state: &mut BrushElementGizmoState) -> Option<(Entity, BrushDesc)> { let rollback = if state.changed { state.brush.zip(state.old_brush.take()) } else { None }; state.old_brush = None; state.old_gizmo = None; state.last_gizmo = None; state.changed = false; rollback } fn clear_brush_element_gizmo(commands: &mut Commands, state: &mut BrushElementGizmoState) { if let Some(entity) = state.entity.take() { if let Ok(mut entity_mut) = commands.get_entity(entity) { entity_mut.despawn(); } } state.brush = None; state.old_brush = None; state.old_gizmo = None; state.last_gizmo = None; state.changed = false; } fn selected_element_world_pivot( brush: &BrushDesc, transform: &GlobalTransform, selected: &[BrushElementKey], ) -> Option { let anchors = selected_anchor_positions(brush, selected); if anchors.is_empty() { return None; } let affine = transform.affine(); Some( anchors .iter() .map(|anchor| affine.transform_point3(*anchor)) .sum::() / anchors.len() as f32, ) } fn gizmo_transform_is_active(state: &BrushElementGizmoState, transform: Transform) -> bool { state .last_gizmo .is_some_and(|last| !transforms_nearly_equal(last, transform)) || state.old_brush.is_some() } fn transform_selected_elements_by_gizmo( brush: &mut BrushDesc, selected: &[BrushElementKey], brush_transform: &GlobalTransform, old_gizmo: Transform, new_gizmo: Transform, ) -> bool { let anchors = selected_anchor_positions(brush, selected); if anchors.is_empty() { return false; } let old_world_from_gizmo = old_gizmo.compute_affine(); let new_world_from_gizmo = new_gizmo.compute_affine(); let world_delta = new_world_from_gizmo * old_world_from_gizmo.inverse(); let brush_from_world = brush_transform.affine().inverse(); let world_from_brush = brush_transform.affine(); 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) { let world = world_from_brush.transform_point3(*vertex); let transformed = world_delta.transform_point3(world); let local = brush_from_world.transform_point3(transformed); if vertex.distance_squared(local) > 0.000001 { *vertex = local; changed = true; } } } } if changed { recompute_face_planes(brush); } changed } fn transforms_nearly_equal(a: Transform, b: Transform) -> bool { a.translation.distance_squared(b.translation) <= 0.000001 && a.rotation.dot(b.rotation).abs() >= 0.99999 && a.scale.distance_squared(b.scale) <= 0.000001 } fn brush_clip_commit( mode: Res, selection: Res, keys: Res>, mut scene_io: ResMut, mut commands: Commands, brushes: Query<(&BrushDesc, &Transform)>, ) { if !matches!(*mode, BrushEditMode::Clip) || !keys.just_pressed(KeyCode::Enter) { return; } let Some(brush_entity) = selection.brush else { scene_io.status = "Clip failed: select a brush face".into(); return; }; let Some(face_id) = single_selected_face(&selection) else { scene_io.status = "Clip failed: select exactly one brush face".into(); return; }; let Ok((brush, transform)) = brushes.get(brush_entity) else { scene_io.status = "Clip failed: selected brush no longer exists".into(); return; }; let Some(result) = clip_preview_result(brush, transform, face_id) else { scene_io.status = "Clip failed: selected face cannot define a clip plane".into(); return; }; scene_io.status = "Brush clip committed".into(); commands.queue(move |world: &mut World| { set_brush_transform_with_history(world, brush_entity, result.transform, result.brush); }); } fn draw_brush_edit_overlays( mode: Res, selection: Res, ui_state: Res, brushes: Query<(Entity, &BrushDesc, &GlobalTransform)>, mut gizmos: Gizmos, ) { if !mode.is_element_mode() { return; } let selected_brushes: Vec = 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, ); if matches!(*mode, BrushEditMode::Clip) && active { if let Some(face_id) = single_selected_face(&selection) { if let Some(result) = clip_preview_result_from_global(brush, transform, face_id) { draw_clip_preview(&mut gizmos, result.world_min, result.world_max); } } } } } 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.31, 0.75, 0.88), BrushEditMode::Edge => Color::srgb(0.95, 0.62, 0.2), BrushEditMode::Face => Color::srgb(0.32, 0.82, 0.52), BrushEditMode::Clip => Color::srgb(0.86, 0.42, 0.92), BrushEditMode::Object => Color::WHITE, } .with_alpha(if active { 0.9 } else { 0.38 }); let selected_color = Color::srgb(1.0, 0.76, 0.34); 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, if active && selected.contains(&key) { 0.11 } else { 0.075 }, color, ); gizmos.line(face_center, face_center + normal * 0.65, color); if matches!(mode, BrushEditMode::Face) { for index in 0..vertices.len() { gizmos.line( vertices[index], vertices[(index + 1) % vertices.len()], color.with_alpha(if active && selected.contains(&key) { 1.0 } else { 0.42 }), ); } } } 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], if active && selected.contains(&key) { 0.1 } else { 0.055 }, 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); if active && selected.contains(&key) { gizmos.sphere(vertices[index], 0.07, color); gizmos.sphere(vertices[next], 0.07, color); } } } } } #[derive(Debug, Clone)] struct ClipPreviewResult { brush: BrushDesc, transform: Transform, world_min: Vec3, world_max: Vec3, } fn single_selected_face(selection: &BrushElementSelection) -> Option<&ComponentInstanceId> { let mut faces = selection .elements .iter() .filter_map(|element| match element { BrushElementKey::Face { face } => Some(face), _ => None, }); let face = faces.next()?; faces.next().is_none().then_some(face) } fn clip_preview_result( brush: &BrushDesc, transform: &Transform, face_id: &ComponentInstanceId, ) -> Option { let bounds = local_brush_bounds(brush)?; let face = brush.faces.iter().find(|face| &face.id == face_id)?; let normal = face.plane.normal.try_normalize()?; let (axis, sign) = dominant_axis(normal)?; let mut min = bounds.0; let mut max = bounds.1; let center = (min + max) * 0.5; match (axis, sign.is_sign_positive()) { (0, true) => min.x = center.x, (0, false) => max.x = center.x, (1, true) => min.y = center.y, (1, false) => max.y = center.y, (2, true) => min.z = center.z, (2, false) => max.z = center.z, _ => return None, } if (max - min).cmple(Vec3::splat(0.001)).any() { return None; } let local_center = (min + max) * 0.5; let local_size = max - min; let mut new_transform = *transform; new_transform.translation = transform.compute_affine().transform_point3(local_center); let brush = BrushDesc::cuboid(local_size); let (world_min, world_max) = world_bounds_for_local_box(min, max, transform); Some(ClipPreviewResult { brush, transform: new_transform, world_min, world_max, }) } fn clip_preview_result_from_global( brush: &BrushDesc, transform: &GlobalTransform, face_id: &ComponentInstanceId, ) -> Option { let (scale, rotation, translation) = transform.to_scale_rotation_translation(); let local_transform = Transform { translation, rotation, scale, }; clip_preview_result(brush, &local_transform, face_id) } fn local_brush_bounds(brush: &BrushDesc) -> Option<(Vec3, Vec3)> { let mut min = Vec3::splat(f32::INFINITY); let mut max = Vec3::splat(f32::NEG_INFINITY); let mut found = false; for vertex in brush .faces .iter() .flat_map(|face| face.vertices.iter().copied()) { if !vertex.is_finite() { continue; } min = min.min(vertex); max = max.max(vertex); found = true; } found.then_some((min, max)) } fn dominant_axis(normal: Vec3) -> Option<(usize, f32)> { let abs = normal.abs(); if abs.max_element() <= 0.0001 { return None; } if abs.x >= abs.y && abs.x >= abs.z { Some((0, normal.x.signum())) } else if abs.y >= abs.z { Some((1, normal.y.signum())) } else { Some((2, normal.z.signum())) } } fn world_bounds_for_local_box(min: Vec3, max: Vec3, transform: &Transform) -> (Vec3, Vec3) { let affine = transform.compute_affine(); let mut world_min = Vec3::splat(f32::INFINITY); let mut world_max = Vec3::splat(f32::NEG_INFINITY); for corner in [ Vec3::new(min.x, min.y, min.z), Vec3::new(max.x, min.y, min.z), Vec3::new(max.x, max.y, min.z), Vec3::new(min.x, max.y, min.z), Vec3::new(min.x, min.y, max.z), Vec3::new(max.x, min.y, max.z), Vec3::new(max.x, max.y, max.z), Vec3::new(min.x, max.y, max.z), ] { let world = affine.transform_point3(corner); world_min = world_min.min(world); world_max = world_max.max(world); } (world_min, world_max) } fn draw_clip_preview(gizmos: &mut Gizmos, min: Vec3, max: Vec3) { let color = Color::srgba(0.95, 0.35, 1.0, 0.95); let corners = [ Vec3::new(min.x, min.y, min.z), Vec3::new(max.x, min.y, min.z), Vec3::new(max.x, max.y, min.z), Vec3::new(min.x, max.y, min.z), Vec3::new(min.x, min.y, max.z), Vec3::new(max.x, min.y, max.z), Vec3::new(max.x, max.y, max.z), Vec3::new(min.x, max.y, max.z), ]; for (a, b) in [ (0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7), ] { gizmos.line(corners[a], corners[b], color); } } fn selected_brush_entity( ui_state: &UiState, brushes: &Query<(), With>, ) -> Option { ui_state .selected_entities .iter() .find(|entity| brushes.contains(*entity)) } fn viewport_ray( cameras: &Query<(&Camera, &GlobalTransform), With>, pointer_pos: egui::Pos2, scene_rect: egui::Rect, ) -> Option { 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 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, 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 } pub(crate) fn pick_brush_face_id( brush: &BrushDesc, transform: &GlobalTransform, ray: Ray3d, ) -> Option<(ComponentInstanceId, f32)> { let (BrushElementKey::Face { face }, distance) = pick_face(brush, transform, ray)? else { return None; }; Some((face, distance)) } fn world_vertices(face: &BrushFaceDesc, transform: &GlobalTransform) -> Vec { face.vertices .iter() .map(|vertex| transform.transform_point(*vertex)) .collect() } fn average_point(points: &[Vec3]) -> Vec3 { points.iter().copied().sum::() / 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, ) { 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 crate::history::EditorHistory; use crate::operators::test_harness::{assert_undo_redo_round_trip, OperatorInvariantHarness}; use crate::scene_io::SceneIo; use crate::selection::SelectedEntity; use shared::{BrushDesc, LevelObject}; #[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 = transform_selected_elements_by_gizmo( &mut brush, &[BrushElementKey::Vertex { face, index: 0 }], &GlobalTransform::default(), Transform::default(), Transform::from_translation(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); } #[test] fn collapsed_face_move_produces_invalid_brush() { let mut brush = BrushDesc::default(); let face = brush.faces[0].id.clone(); assert!(transform_selected_elements_by_gizmo( &mut brush, &[BrushElementKey::Face { face }], &GlobalTransform::default(), Transform::default(), Transform::from_translation(Vec3::new(-1.0, 0.0, 0.0)), )); assert!(!validate_brush(&brush).is_valid()); } #[test] fn clip_preview_keeps_half_along_selected_face_normal() { let brush = BrushDesc::default(); let face = brush.faces[0].id.clone(); let result = clip_preview_result(&brush, &Transform::default(), &face).expect("clip"); let (min, max) = local_brush_bounds(&result.brush).expect("bounds"); assert_eq!(max - min, Vec3::new(0.5, 1.0, 1.0)); assert_eq!(result.transform.translation, Vec3::new(0.25, 0.0, 0.0)); assert!(validate_brush(&result.brush).is_valid()); } #[test] fn clip_history_command_is_grouped() { let command = EditorCommand::SetBrushTransform { entity: Entity::PLACEHOLDER, old_transform: Transform::default(), new_transform: Transform::from_translation(Vec3::X), old_brush: Some(BrushDesc::default()), new_brush: BrushDesc::default(), }; assert_eq!(command.label(), "Clip Brush"); } #[test] fn clip_commit_is_one_undo_group_and_round_trips_geometry() { let mut world = World::new(); world.init_resource::(); world.init_resource::(); world.init_resource::(); world.init_resource::(); let initial_transform = Transform::default(); let initial_brush = BrushDesc::default(); let entity = world .spawn((LevelObject, initial_transform, initial_brush.clone())) .id(); let face = initial_brush.faces[0].id.clone(); let result = clip_preview_result(&initial_brush, &initial_transform, &face).unwrap(); let committed = (result.transform, result.brush.clone()); let harness = OperatorInvariantHarness::capture(&mut world); set_brush_transform_with_history(&mut world, entity, result.transform, result.brush); set_brush_edit_status( &mut world.resource_mut::(), OperatorPhase::Committed, "Brush clip committed", ); harness.assert_committed(&mut world, 1, 0); assert_undo_redo_round_trip( &mut world, (initial_transform, initial_brush), committed, |world| { ( *world.get::(entity).unwrap(), world.get::(entity).unwrap().clone(), ) }, ); } #[test] fn escape_from_element_preview_restores_brush_and_despawns_helper() { let mut app = App::new(); app.init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .add_systems(Update, sync_brush_element_gizmo); let initial = BrushDesc::default(); let mut preview = initial.clone(); preview.faces[0].vertices[0] += Vec3::X * 0.25; let brush_entity = app .world_mut() .spawn((LevelObject, preview, GlobalTransform::default())) .id(); let helper = app.world_mut().spawn(BrushElementGizmo).id(); *app.world_mut().resource_mut::() = BrushEditMode::Object; *app.world_mut().resource_mut::() = BrushElementGizmoState { entity: Some(helper), brush: Some(brush_entity), old_brush: Some(initial.clone()), old_gizmo: Some(Transform::default()), last_gizmo: Some(Transform::from_translation(Vec3::X)), changed: true, }; set_brush_edit_status( &mut app.world_mut().resource_mut::(), OperatorPhase::Canceled, "Object mode", ); let harness = OperatorInvariantHarness::capture(app.world_mut()); app.update(); harness.assert_canceled(app.world_mut()); assert_eq!(app.world().get::(brush_entity), Some(&initial)); harness.assert_no_helpers::(app.world_mut()); let state = app.world().resource::(); assert!(state.entity.is_none()); assert!(state.old_brush.is_none()); assert!(!state.changed); } #[test] fn brush_element_release_uses_production_finalizer_and_round_trips() { let mut app = App::new(); app.init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .init_resource::() .add_systems(Update, apply_brush_element_gizmo); let original = BrushDesc::cuboid(Vec3::ONE); let preview = BrushDesc::cuboid(Vec3::new(1.5, 1.0, 1.0)); let brush_entity = app .world_mut() .spawn((LevelObject, preview.clone(), GlobalTransform::default())) .id(); let helper = app .world_mut() .spawn(( BrushElementGizmo, Transform::default(), GizmoTarget::default(), )) .id(); *app.world_mut().resource_mut::() = BrushEditMode::Vertex; *app.world_mut().resource_mut::() = BrushElementSelection { brush: Some(brush_entity), elements: vec![BrushElementKey::Vertex { face: original.faces[0].id.clone(), index: 0, }], }; *app.world_mut().resource_mut::() = BrushElementGizmoState { entity: Some(helper), brush: Some(brush_entity), old_brush: Some(original.clone()), old_gizmo: Some(Transform::default()), last_gizmo: Some(Transform::from_translation(Vec3::X * 0.25)), changed: true, }; let harness = OperatorInvariantHarness::capture(app.world_mut()); app.update(); harness.assert_committed(app.world_mut(), 1, 0); harness.assert_status(app.world(), "brush.edit_mode", OperatorPhase::Committed); assert_undo_redo_round_trip(app.world_mut(), original, preview, |world| { world.get::(brush_entity).unwrap().clone() }); } }