//! Modal terrain height sculpting with one history transaction per pointer stroke. use bevy::prelude::*; use bevy_egui::EguiContexts; use shared::{TerrainDesc, AUTHORING_COMPONENT_TERRAIN, COMPONENT_TERRAIN_DESC}; use crate::camera::EditorCamera; use crate::history::{reflected_component_transaction, EditorHistory}; use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus}; use crate::scene_io::SceneIo; use crate::selection::{SelectedEntity, ViewportClick}; use crate::state::scene_tools_active; use crate::ui::UiState; use crate::viewport::{scene_view_ray, ViewportDisplayMode}; const MIN_RADIUS: f32 = 0.25; const MAX_RADIUS: f32 = 128.0; const MIN_STRENGTH: f32 = 0.01; const MAX_STRENGTH: f32 = 20.0; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum TerrainSculptMode { #[default] Raise, Lower, Flatten, Smooth, Noise, } impl TerrainSculptMode { pub fn label(self) -> &'static str { match self { Self::Raise => "Raise", Self::Lower => "Lower", Self::Flatten => "Flatten", Self::Smooth => "Smooth", Self::Noise => "Noise", } } } #[derive(Debug, Clone, Copy)] pub(super) struct TerrainHit { pub(super) entity: Entity, pub(super) local: Vec3, pub(super) world: Vec3, pub(super) world_rotation: Quat, } #[derive(Debug, Clone)] struct TerrainStroke { entity: Entity, original: TerrainDesc, last_local: Vec3, flatten_height: f32, seed: u32, } #[derive(Resource, Debug, Clone)] pub struct TerrainSculptState { pub active: bool, pub mode: TerrainSculptMode, pub radius: f32, /// Approximate world-space height delta per dab. pub strength: f32, hover: Option, stroke: Option, target: Option, next_seed: u32, } impl Default for TerrainSculptState { fn default() -> Self { Self { active: false, mode: TerrainSculptMode::Raise, radius: 4.0, strength: 0.35, hover: None, stroke: None, target: None, next_seed: 1, } } } impl TerrainSculptState { pub fn start(&mut self, target: Entity) { self.active = true; self.target = Some(target); } pub fn stop(&mut self) { self.active = false; self.hover = None; self.target = None; } pub fn is_stroking(&self) -> bool { self.stroke.is_some() } pub fn clamp_settings(&mut self) { self.radius = self.radius.clamp(MIN_RADIUS, MAX_RADIUS); self.strength = self.strength.clamp(MIN_STRENGTH, MAX_STRENGTH); } } pub struct TerrainSculptPlugin; impl Plugin for TerrainSculptPlugin { fn build(&self, app: &mut App) { app.init_resource::().add_systems( Update, (terrain_sculpt_input, draw_terrain_sculpt_preview) .chain() .run_if(scene_tools_active), ); } } #[allow(clippy::too_many_arguments)] fn terrain_sculpt_input( mut commands: Commands, mut state: ResMut, mut selected: ResMut, keys: Res>, buttons: Res>, mut contexts: EguiContexts, ui_state: Res, display: Res, cameras: Query<(&Camera, &GlobalTransform), With>, mut terrains: Query<(&GlobalTransform, &mut TerrainDesc)>, mut viewport_click: ResMut, mut scene_io: ResMut, mut active_operator: ResMut, ) -> Result { if !state.active { return Ok(()); } viewport_click.0 = None; state.clamp_settings(); let Some(entity) = state.target.or(selected.0) else { stop_tool_with_rollback( &mut state, &mut terrains, &mut scene_io, &mut active_operator, OperatorPhase::Blocked, "Terrain sculpt stopped: target is unavailable", "Target unavailable", ); return Ok(()); }; if selected.0 != Some(entity) { selected.0 = Some(entity); } if display.clean_game_view { stop_tool_with_rollback( &mut state, &mut terrains, &mut scene_io, &mut active_operator, OperatorPhase::Canceled, "Terrain sculpt stopped for clean game view", "Clean game view enabled; terrain restored", ); return Ok(()); } let ctx = contexts.ctx_mut()?; let pointer_over_ui = ctx.egui_wants_pointer_input(); let cancel_requested = keys.just_pressed(KeyCode::Escape) || buttons.just_pressed(MouseButton::Right); if cancel_requested { if state.is_stroking() { cancel_active_stroke( &mut state, &mut terrains, &mut scene_io, &mut active_operator, ); } else { state.stop(); scene_io.status = "Terrain sculpt tool closed".to_string(); set_status(&mut active_operator, OperatorPhase::Canceled, "Tool closed"); } return Ok(()); } let Ok((global, mut terrain)) = terrains.get_mut(entity) else { stop_tool_with_rollback( &mut state, &mut terrains, &mut scene_io, &mut active_operator, OperatorPhase::Blocked, "Terrain sculpt stopped: target no longer exists", "Target no longer exists", ); return Ok(()); }; let hit = ui_state .viewport_pointer_pos .and_then(|pointer| active_scene_ray(&cameras, pointer, ui_state.viewport_rect)) .and_then(|ray| terrain_ray_hit(entity, &terrain, global, ray)); state.hover = hit; if buttons.just_pressed(MouseButton::Left) && !pointer_over_ui { if let Some(hit) = state.hover.filter(|hit| hit.entity == entity) { let flatten_height = sample_height_bilinear(&terrain, hit.local.x, hit.local.z).unwrap_or_default(); let seed = state.next_seed; state.next_seed = state.next_seed.wrapping_add(1).max(1); state.stroke = Some(TerrainStroke { entity, original: terrain.clone(), last_local: hit.local, flatten_height, seed, }); apply_dab( &mut terrain, hit.local, state.radius, state.strength, state.mode, flatten_height, seed, ); set_status( &mut active_operator, OperatorPhase::Preview, format!("{} stroke in progress", state.mode.label()), ); } } else if buttons.pressed(MouseButton::Left) { let radius = state.radius; let strength = state.strength; let mode = state.mode; if let (Some(hit), Some(stroke)) = (state.hover, state.stroke.as_mut()) { if hit.entity == stroke.entity { let step = (radius * 0.2).max(terrain.sample_spacing * 0.25); let delta = hit.local - stroke.last_local; let distance = Vec2::new(delta.x, delta.z).length(); if distance >= step { let count = (distance / step).floor() as usize; for index in 1..=count { let point = stroke.last_local + delta * (index as f32 / count as f32); apply_dab( &mut terrain, point, radius, strength, mode, stroke.flatten_height, stroke.seed, ); } stroke.last_local = hit.local; } } } } if buttons.just_released(MouseButton::Left) { if let Some(stroke) = state.stroke.take() { let final_terrain = terrain.clone(); let label = sculpt_history_label(state.mode); let mode_label = state.mode.label(); commands.queue(move |world: &mut World| { finish_terrain_stroke(world, stroke, final_terrain, label, mode_label); }); } } Ok(()) } fn commit_terrain_stroke( world: &mut World, stroke: TerrainStroke, final_terrain: TerrainDesc, label: &'static str, ) -> Result<(), String> { let original = stroke.original; if let Ok(mut actor) = world.get_entity_mut(stroke.entity) { actor.insert(original.clone()); } let result = reflected_component_transaction( world, stroke.entity, label, AUTHORING_COMPONENT_TERRAIN, COMPONENT_TERRAIN_DESC, move |world, entity| { world.entity_mut(entity).insert(final_terrain); Ok(()) }, ); if result.is_err() { if let Ok(mut actor) = world.get_entity_mut(stroke.entity) { actor.insert(original); } } result } fn finish_terrain_stroke( world: &mut World, stroke: TerrainStroke, final_terrain: TerrainDesc, label: &'static str, mode_label: &'static str, ) { let undo_depth = world.resource::().undo_depth(); match commit_terrain_stroke(world, stroke, final_terrain, label) { Ok(()) if world.resource::().undo_depth() > undo_depth => { world.resource_mut::().status = format!("{mode_label} terrain stroke committed"); set_status( &mut world.resource_mut::(), OperatorPhase::Committed, format!("{mode_label} stroke; Ctrl+Z to undo"), ); } Ok(()) => { world.resource_mut::().status = format!("{mode_label} terrain stroke made no changes"); set_status( &mut world.resource_mut::(), OperatorPhase::Canceled, format!("{mode_label} stroke made no changes"), ); } Err(error) => { world.resource_mut::().status = format!("Terrain sculpt commit failed: {error}"); set_status( &mut world.resource_mut::(), OperatorPhase::Blocked, format!("Commit failed: {error}"), ); } } } fn cancel_active_stroke( state: &mut TerrainSculptState, terrains: &mut Query<(&GlobalTransform, &mut TerrainDesc)>, scene_io: &mut SceneIo, active_operator: &mut ActiveOperator, ) { cancel_stroke(state, terrains); scene_io.status = "Terrain sculpt stroke canceled".to_string(); set_status( active_operator, OperatorPhase::Canceled, "Stroke canceled; terrain restored", ); } fn stop_tool_with_rollback( state: &mut TerrainSculptState, terrains: &mut Query<(&GlobalTransform, &mut TerrainDesc)>, scene_io: &mut SceneIo, active_operator: &mut ActiveOperator, phase: OperatorPhase, message: &str, hint: &str, ) { cancel_stroke(state, terrains); state.stop(); scene_io.status = message.to_string(); set_status(active_operator, phase, hint); } fn cancel_stroke( state: &mut TerrainSculptState, terrains: &mut Query<(&GlobalTransform, &mut TerrainDesc)>, ) { let Some(stroke) = state.stroke.take() else { return; }; if let Ok((_, mut terrain)) = terrains.get_mut(stroke.entity) { *terrain = stroke.original; } } fn set_status(active: &mut ActiveOperator, phase: OperatorPhase, hint: impl Into) { active.status = Some(OperatorStatus { id: "terrain.sculpt".to_string(), label: "Terrain Sculpt".to_string(), phase, hint: hint.into(), warnings: Vec::new(), }); } fn sculpt_history_label(mode: TerrainSculptMode) -> &'static str { match mode { TerrainSculptMode::Raise => "Raise Terrain", TerrainSculptMode::Lower => "Lower Terrain", TerrainSculptMode::Flatten => "Flatten Terrain", TerrainSculptMode::Smooth => "Smooth Terrain", TerrainSculptMode::Noise => "Noise Terrain", } } pub(super) fn active_scene_ray( cameras: &Query<(&Camera, &GlobalTransform), With>, pointer: bevy_egui::egui::Pos2, scene_rect: bevy_egui::egui::Rect, ) -> Option { let (camera, transform) = cameras .iter() .find(|(camera, _)| camera.is_active) .or_else(|| cameras.iter().next())?; scene_view_ray(camera, transform, pointer, scene_rect) } pub(super) fn terrain_ray_hit( entity: Entity, terrain: &TerrainDesc, global: &GlobalTransform, ray: Ray3d, ) -> Option { let world_from_local = global.affine(); let local_from_world = world_from_local.inverse(); let origin = local_from_world.transform_point3(ray.origin); let direction = local_from_world.transform_vector3(ray.direction.as_vec3()); let local = ray_heightfield_intersection(terrain, origin, direction)?; Some(TerrainHit { entity, local, world: world_from_local.transform_point3(local), world_rotation: global.rotation(), }) } fn ray_heightfield_intersection( terrain: &TerrainDesc, origin: Vec3, direction: Vec3, ) -> Option { if terrain.validate().is_err() || direction.length_squared() <= f32::EPSILON { return None; } let horizontal_speed = Vec2::new(direction.x, direction.z).length(); if horizontal_speed <= 1.0e-6 { if direction.y.abs() <= 1.0e-6 { return None; } let height = sample_height_bilinear(terrain, origin.x, origin.z)?; let t = (height - origin.y) / direction.y; return (t >= 0.0).then_some(origin + direction * t); } let half_extent = (terrain.resolution - 1) as f32 * terrain.sample_spacing * 0.5; let (mut start, mut end) = (0.0_f32, f32::INFINITY); for (origin_axis, direction_axis) in [(origin.x, direction.x), (origin.z, direction.z)] { if direction_axis.abs() <= 1.0e-6 { if origin_axis < -half_extent || origin_axis > half_extent { return None; } continue; } let a = (-half_extent - origin_axis) / direction_axis; let b = (half_extent - origin_axis) / direction_axis; start = start.max(a.min(b)); end = end.min(a.max(b)); } if end < start || end < 0.0 { return None; } start = start.max(0.0); let span = (end - start).max(0.0); let steps = ((span * horizontal_speed) / (terrain.sample_spacing * 0.4)) .ceil() .clamp(1.0, 8192.0) as usize; let surface_delta = |t: f32| { let point = origin + direction * t; sample_height_bilinear(terrain, point.x, point.z).map(|height| point.y - height) }; let mut previous_t = start; let mut previous = surface_delta(start)?; if previous.abs() <= 1.0e-4 { return Some(origin + direction * start); } for step in 1..=steps { let t = start + span * (step as f32 / steps as f32); let current = surface_delta(t)?; if current.signum() != previous.signum() || current.abs() <= 1.0e-4 { let (mut low, mut high) = (previous_t, t); let low_sign = previous.signum(); for _ in 0..14 { let middle = (low + high) * 0.5; let delta = surface_delta(middle)?; if delta.signum() == low_sign { low = middle; } else { high = middle; } } let hit_t = (low + high) * 0.5; return Some(origin + direction * hit_t); } previous_t = t; previous = current; } None } fn sample_height_bilinear(terrain: &TerrainDesc, local_x: f32, local_z: f32) -> Option { let half_extent = (terrain.resolution - 1) as f32 * terrain.sample_spacing * 0.5; let x = (local_x + half_extent) / terrain.sample_spacing; let z = (local_z + half_extent) / terrain.sample_spacing; let max = (terrain.resolution - 1) as f32; if x < 0.0 || z < 0.0 || x > max || z > max { return None; } let x0 = x.floor() as u32; let z0 = z.floor() as u32; let x1 = (x0 + 1).min(terrain.resolution - 1); let z1 = (z0 + 1).min(terrain.resolution - 1); let sample = |sx: u32, sz: u32| { terrain.heights[(sz * terrain.resolution + sx) as usize] * terrain.height_scale }; let top = sample(x0, z0).lerp(sample(x1, z0), x - x0 as f32); let bottom = sample(x0, z1).lerp(sample(x1, z1), x - x0 as f32); Some(top.lerp(bottom, z - z0 as f32)) } fn apply_dab( terrain: &mut TerrainDesc, center: Vec3, radius: f32, strength: f32, mode: TerrainSculptMode, flatten_height: f32, seed: u32, ) { let radius = radius.max(MIN_RADIUS); let resolution = terrain.resolution; let half_extent = (resolution - 1) as f32 * terrain.sample_spacing * 0.5; let source = terrain.heights.clone(); let normalized_strength = strength / terrain.height_scale.max(0.01); for z in 0..resolution { for x in 0..resolution { let sample_position = Vec2::new( x as f32 * terrain.sample_spacing - half_extent, z as f32 * terrain.sample_spacing - half_extent, ); let distance = sample_position.distance(Vec2::new(center.x, center.z)); if distance > radius { continue; } let falloff = smooth_falloff(distance / radius); let index = (z * resolution + x) as usize; let current = source[index]; let next = match mode { TerrainSculptMode::Raise => current + normalized_strength * falloff, TerrainSculptMode::Lower => current - normalized_strength * falloff, TerrainSculptMode::Flatten => { let target = flatten_height / terrain.height_scale.max(0.01); current.lerp(target, (normalized_strength * falloff).clamp(0.0, 1.0)) } TerrainSculptMode::Smooth => { let average = neighbor_average(&source, resolution, x, z); current.lerp(average, (normalized_strength * falloff).clamp(0.0, 1.0)) } TerrainSculptMode::Noise => { current + signed_noise(x, z, seed) * normalized_strength * falloff } }; terrain.heights[index] = next; } } } pub(super) fn smooth_falloff(normalized_distance: f32) -> f32 { let t = (1.0 - normalized_distance).clamp(0.0, 1.0); t * t * (3.0 - 2.0 * t) } fn neighbor_average(heights: &[f32], resolution: u32, x: u32, z: u32) -> f32 { let mut sum = 0.0; let mut count = 0; for dz in -1_i32..=1 { for dx in -1_i32..=1 { let sx = x as i32 + dx; let sz = z as i32 + dz; if sx >= 0 && sz >= 0 && sx < resolution as i32 && sz < resolution as i32 { sum += heights[(sz as u32 * resolution + sx as u32) as usize]; count += 1; } } } sum / count as f32 } fn signed_noise(x: u32, z: u32, seed: u32) -> f32 { let mut value = x .wrapping_mul(0x9e37_79b9) .wrapping_add(z.wrapping_mul(0x85eb_ca6b)) .wrapping_add(seed.wrapping_mul(0xc2b2_ae35)); value ^= value >> 16; value = value.wrapping_mul(0x7feb_352d); value ^= value >> 15; value = value.wrapping_mul(0x846c_a68b); value ^= value >> 16; (value as f32 / u32::MAX as f32) * 2.0 - 1.0 } fn draw_terrain_sculpt_preview(state: Res, mut gizmos: Gizmos) { if !state.active { return; } let Some(hit) = state.hover else { return; }; let color = match state.mode { TerrainSculptMode::Raise => Color::srgba(0.30, 0.92, 0.58, 0.95), TerrainSculptMode::Lower => Color::srgba(0.96, 0.36, 0.32, 0.95), TerrainSculptMode::Flatten => Color::srgba(0.32, 0.72, 1.0, 0.95), TerrainSculptMode::Smooth => Color::srgba(0.72, 0.56, 1.0, 0.95), TerrainSculptMode::Noise => Color::srgba(1.0, 0.76, 0.28, 0.95), }; let rotation = hit.world_rotation * Quat::from_rotation_x(std::f32::consts::FRAC_PI_2); gizmos .circle( Isometry3d::new(hit.world + Vec3::Y * 0.03, rotation), state.radius, color, ) .resolution(48); gizmos.sphere( Isometry3d::from_translation(hit.world + Vec3::Y * 0.035), 0.06, color, ); } #[cfg(test)] mod tests { use super::*; use crate::history::EditorHistory; use crate::operators::test_harness::{assert_undo_redo_round_trip, OperatorInvariantHarness}; use shared::{ActorKind, LevelObject}; fn sample(terrain: &TerrainDesc, x: u32, z: u32) -> f32 { terrain.heights[(z * terrain.resolution + x) as usize] } #[test] fn vertical_ray_hits_heightfield_surface() { let mut terrain = TerrainDesc::flat(3); terrain.height_scale = 2.0; terrain.heights[4] = 1.0; let hit = ray_heightfield_intersection(&terrain, Vec3::new(0.0, 5.0, 0.0), Vec3::NEG_Y) .expect("ray should hit center sample"); assert!((hit.y - 2.0).abs() < 0.001); } #[test] fn raise_and_lower_only_touch_brush_footprint() { let mut terrain = TerrainDesc::flat(5); apply_dab( &mut terrain, Vec3::ZERO, 1.1, 1.0, TerrainSculptMode::Raise, 0.0, 1, ); assert!(sample(&terrain, 2, 2) > 0.0); assert_eq!(sample(&terrain, 0, 0), 0.0); let raised = sample(&terrain, 2, 2); apply_dab( &mut terrain, Vec3::ZERO, 1.1, 1.0, TerrainSculptMode::Lower, 0.0, 1, ); assert!(sample(&terrain, 2, 2) < raised); } #[test] fn flatten_and_smooth_converge_toward_targets() { let mut terrain = TerrainDesc::flat(3); terrain.height_scale = 1.0; terrain.heights[4] = 4.0; apply_dab( &mut terrain, Vec3::ZERO, 2.0, 1.0, TerrainSculptMode::Smooth, 0.0, 2, ); assert!(sample(&terrain, 1, 1) < 4.0); apply_dab( &mut terrain, Vec3::ZERO, 2.0, 0.5, TerrainSculptMode::Flatten, 2.0, 2, ); assert!(sample(&terrain, 1, 1) > 0.0); } #[test] fn noise_is_deterministic_for_stroke_seed() { let mut first = TerrainDesc::flat(5); let mut second = first.clone(); for terrain in [&mut first, &mut second] { apply_dab( terrain, Vec3::ZERO, 3.0, 0.4, TerrainSculptMode::Noise, 0.0, 42, ); } assert_eq!(first.heights, second.heights); assert!(first.heights.iter().any(|height| *height != 0.0)); } #[test] fn many_dabs_commit_as_one_undoable_stroke() { let mut app = App::new(); app.register_type::() .register_type::(); let world = app.world_mut(); world.init_resource::(); world.init_resource::(); world.init_resource::(); world.init_resource::(); let original = TerrainDesc::flat(9); let entity = world .spawn((LevelObject, ActorKind::Terrain, original.clone())) .id(); let mut final_terrain = original.clone(); for x in [-2.0, 0.0, 2.0] { apply_dab( &mut final_terrain, Vec3::new(x, 0.0, 0.0), 2.5, 0.4, TerrainSculptMode::Raise, 0.0, 7, ); } world.entity_mut(entity).insert(final_terrain.clone()); let stroke = TerrainStroke { entity, original: original.clone(), last_local: Vec3::ZERO, flatten_height: 0.0, seed: 7, }; { let mut state = world.resource_mut::(); state.start(entity); state.stroke = Some(stroke); } let stroke = world .resource_mut::() .stroke .take() .unwrap(); let harness = OperatorInvariantHarness::capture(world); finish_terrain_stroke( world, stroke, final_terrain.clone(), "Raise Terrain", "Raise", ); harness.assert_committed(world, 1, 0); harness.assert_status(world, "terrain.sculpt", OperatorPhase::Committed); assert_eq!(world.get::(entity), Some(&final_terrain)); let state = world.resource::(); assert!(state.active); assert_eq!(state.target, Some(entity)); assert!(!state.is_stroking()); assert_undo_redo_round_trip(world, original, final_terrain, |world| { world.get::(entity).unwrap().clone() }); } #[test] fn no_op_stroke_terminates_without_dirtying_or_history() { let mut app = App::new(); app.register_type::() .register_type::(); let world = app.world_mut(); world.init_resource::(); world.init_resource::(); world.init_resource::(); let original = TerrainDesc::flat(5); let entity = world .spawn((LevelObject, ActorKind::Terrain, original.clone())) .id(); let harness = OperatorInvariantHarness::capture(world); finish_terrain_stroke( world, TerrainStroke { entity, original: original.clone(), last_local: Vec3::ZERO, flatten_height: 0.0, seed: 1, }, original.clone(), "Smooth Terrain", "Smooth", ); harness.assert_canceled(world); harness.assert_status(world, "terrain.sculpt", OperatorPhase::Canceled); assert_eq!(world.get::(entity), Some(&original)); } #[test] fn commit_failure_restores_preview_and_reports_blocked_without_history() { let mut app = App::new(); let world = app.world_mut(); world.init_resource::(); world.init_resource::(); world.init_resource::(); let original = TerrainDesc::flat(5); let mut preview = original.clone(); apply_dab( &mut preview, Vec3::ZERO, 3.0, 0.8, TerrainSculptMode::Raise, 0.0, 11, ); let entity = world .spawn((LevelObject, ActorKind::Terrain, preview.clone())) .id(); let mut hierarchy = crate::ui::hierarchy_state::HierarchyPanelState::default(); hierarchy.locked.insert(entity); world.insert_resource(hierarchy); let harness = OperatorInvariantHarness::capture(world); finish_terrain_stroke( world, TerrainStroke { entity, original: original.clone(), last_local: Vec3::ZERO, flatten_height: 0.0, seed: 11, }, preview, "Raise Terrain", "Raise", ); harness.assert_blocked(world); harness.assert_status(world, "terrain.sculpt", OperatorPhase::Blocked); assert_eq!(world.get::(entity), Some(&original)); assert!(world .resource::() .status .starts_with("Terrain sculpt commit failed:")); } #[test] fn cancel_restores_exact_pre_stroke_descriptor() { fn cancel_once( mut state: ResMut, mut terrains: Query<(&GlobalTransform, &mut TerrainDesc)>, mut scene_io: ResMut, mut active_operator: ResMut, ) { cancel_active_stroke( &mut state, &mut terrains, &mut scene_io, &mut active_operator, ); } let mut app = App::new(); app.init_resource::() .init_resource::() .init_resource::(); let original = TerrainDesc::flat(5); let mut preview = original.clone(); apply_dab( &mut preview, Vec3::ZERO, 3.0, 0.8, TerrainSculptMode::Raise, 0.0, 11, ); let entity = app .world_mut() .spawn((LevelObject, GlobalTransform::default(), preview)) .id(); let mut state = TerrainSculptState::default(); state.start(entity); state.stroke = Some(TerrainStroke { entity, original: original.clone(), last_local: Vec3::ZERO, flatten_height: 0.0, seed: 11, }); app.insert_resource(state).add_systems(Update, cancel_once); let harness = OperatorInvariantHarness::capture(app.world_mut()); app.update(); harness.assert_canceled(app.world_mut()); harness.assert_status(app.world(), "terrain.sculpt", OperatorPhase::Canceled); assert_eq!(app.world().get::(entity), Some(&original)); let state = app.world().resource::(); assert!(state.active); assert_eq!(state.target, Some(entity)); assert!(!state.is_stroking()); } #[test] fn clean_view_interruption_restores_stroke_and_terminates_tool() { fn interrupt_once( mut state: ResMut, mut terrains: Query<(&GlobalTransform, &mut TerrainDesc)>, mut scene_io: ResMut, mut active_operator: ResMut, ) { stop_tool_with_rollback( &mut state, &mut terrains, &mut scene_io, &mut active_operator, OperatorPhase::Canceled, "Terrain sculpt stopped for clean game view", "Clean game view enabled; terrain restored", ); } let mut app = App::new(); app.init_resource::() .init_resource::() .init_resource::(); let original = TerrainDesc::flat(5); let mut preview = original.clone(); apply_dab( &mut preview, Vec3::ZERO, 3.0, 0.8, TerrainSculptMode::Raise, 0.0, 11, ); let entity = app .world_mut() .spawn((LevelObject, GlobalTransform::default(), preview)) .id(); let mut state = TerrainSculptState::default(); state.start(entity); state.stroke = Some(TerrainStroke { entity, original: original.clone(), last_local: Vec3::ZERO, flatten_height: 0.0, seed: 11, }); app.insert_resource(state) .add_systems(Update, interrupt_once); let harness = OperatorInvariantHarness::capture(app.world_mut()); app.update(); harness.assert_canceled(app.world_mut()); harness.assert_status(app.world(), "terrain.sculpt", OperatorPhase::Canceled); assert_eq!(app.world().get::(entity), Some(&original)); assert!(!app.world().resource::().active); } }