Blacksite/crates/editor/src/viewport/brush_edit.rs
Rbanh 1ab3886028
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Complete editor operator invariants
2026-07-12 23:53:46 -04:00

1363 lines
44 KiB
Rust

//! 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<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::<BrushElementGizmoState>()
.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<Entity>,
brush: Option<Entity>,
old_brush: Option<BrushDesc>,
old_gizmo: Option<Transform>,
last_gizmo: Option<Transform>,
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.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<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);
}
}
fn sync_brush_element_gizmo(
mut commands: Commands,
mode: Res<BrushEditMode>,
selection: Res<BrushElementSelection>,
mut state: ResMut<BrushElementGizmoState>,
mut brushes: Query<(&mut BrushDesc, &GlobalTransform)>,
mut gizmos: Query<&mut Transform, With<BrushElementGizmo>>,
) {
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<BrushEditMode>,
selection: Res<BrushElementSelection>,
mut state: ResMut<BrushElementGizmoState>,
mut active_operator: ResMut<ActiveOperator>,
mut brushes: Query<(&mut BrushDesc, &GlobalTransform)>,
gizmos: Query<(&Transform, &GizmoTarget), With<BrushElementGizmo>>,
) {
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<Vec3> {
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::<Vec3>()
/ 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<BrushEditMode>,
selection: Res<BrushElementSelection>,
keys: Res<ButtonInput<KeyCode>>,
mut scene_io: ResMut<SceneIo>,
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<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,
);
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<ClipPreviewResult> {
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<ClipPreviewResult> {
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<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 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
}
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<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 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::<ActiveOperator>();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
world.init_resource::<SelectedEntity>();
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::<ActiveOperator>(),
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::<Transform>(entity).unwrap(),
world.get::<BrushDesc>(entity).unwrap().clone(),
)
},
);
}
#[test]
fn escape_from_element_preview_restores_brush_and_despawns_helper() {
let mut app = App::new();
app.init_resource::<ActiveOperator>()
.init_resource::<EditorHistory>()
.init_resource::<SceneIo>()
.init_resource::<BrushEditMode>()
.init_resource::<BrushElementSelection>()
.init_resource::<BrushElementGizmoState>()
.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>() = BrushEditMode::Object;
*app.world_mut().resource_mut::<BrushElementGizmoState>() = 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::<ActiveOperator>(),
OperatorPhase::Canceled,
"Object mode",
);
let harness = OperatorInvariantHarness::capture(app.world_mut());
app.update();
harness.assert_canceled(app.world_mut());
assert_eq!(app.world().get::<BrushDesc>(brush_entity), Some(&initial));
harness.assert_no_helpers::<BrushElementGizmo>(app.world_mut());
let state = app.world().resource::<BrushElementGizmoState>();
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::<ActiveOperator>()
.init_resource::<EditorHistory>()
.init_resource::<SceneIo>()
.init_resource::<BrushEditMode>()
.init_resource::<BrushElementSelection>()
.init_resource::<BrushElementGizmoState>()
.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>() = BrushEditMode::Vertex;
*app.world_mut().resource_mut::<BrushElementSelection>() = BrushElementSelection {
brush: Some(brush_entity),
elements: vec![BrushElementKey::Vertex {
face: original.faces[0].id.clone(),
index: 0,
}],
};
*app.world_mut().resource_mut::<BrushElementGizmoState>() = 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::<BrushDesc>(brush_entity).unwrap().clone()
});
}
}