1363 lines
44 KiB
Rust
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()
|
|
});
|
|
}
|
|
}
|