Blacksite/crates/editor/src/viewport/terrain_sculpt.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

1006 lines
32 KiB
Rust

//! 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<TerrainHit>,
stroke: Option<TerrainStroke>,
target: Option<Entity>,
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::<TerrainSculptState>().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<TerrainSculptState>,
mut selected: ResMut<SelectedEntity>,
keys: Res<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>,
mut contexts: EguiContexts,
ui_state: Res<UiState>,
display: Res<ViewportDisplayMode>,
cameras: Query<(&Camera, &GlobalTransform), With<EditorCamera>>,
mut terrains: Query<(&GlobalTransform, &mut TerrainDesc)>,
mut viewport_click: ResMut<ViewportClick>,
mut scene_io: ResMut<SceneIo>,
mut active_operator: ResMut<ActiveOperator>,
) -> 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::<EditorHistory>().undo_depth();
match commit_terrain_stroke(world, stroke, final_terrain, label) {
Ok(()) if world.resource::<EditorHistory>().undo_depth() > undo_depth => {
world.resource_mut::<SceneIo>().status =
format!("{mode_label} terrain stroke committed");
set_status(
&mut world.resource_mut::<ActiveOperator>(),
OperatorPhase::Committed,
format!("{mode_label} stroke; Ctrl+Z to undo"),
);
}
Ok(()) => {
world.resource_mut::<SceneIo>().status =
format!("{mode_label} terrain stroke made no changes");
set_status(
&mut world.resource_mut::<ActiveOperator>(),
OperatorPhase::Canceled,
format!("{mode_label} stroke made no changes"),
);
}
Err(error) => {
world.resource_mut::<SceneIo>().status =
format!("Terrain sculpt commit failed: {error}");
set_status(
&mut world.resource_mut::<ActiveOperator>(),
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<String>) {
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<EditorCamera>>,
pointer: bevy_egui::egui::Pos2,
scene_rect: bevy_egui::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, scene_rect)
}
pub(super) fn terrain_ray_hit(
entity: Entity,
terrain: &TerrainDesc,
global: &GlobalTransform,
ray: Ray3d,
) -> Option<TerrainHit> {
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<Vec3> {
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<f32> {
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<TerrainSculptState>, 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::<ActorKind>()
.register_type::<TerrainDesc>();
let world = app.world_mut();
world.init_resource::<ActiveOperator>();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
world.init_resource::<TerrainSculptState>();
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::<TerrainSculptState>();
state.start(entity);
state.stroke = Some(stroke);
}
let stroke = world
.resource_mut::<TerrainSculptState>()
.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::<TerrainDesc>(entity), Some(&final_terrain));
let state = world.resource::<TerrainSculptState>();
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::<TerrainDesc>(entity).unwrap().clone()
});
}
#[test]
fn no_op_stroke_terminates_without_dirtying_or_history() {
let mut app = App::new();
app.register_type::<ActorKind>()
.register_type::<TerrainDesc>();
let world = app.world_mut();
world.init_resource::<ActiveOperator>();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
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::<TerrainDesc>(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::<ActiveOperator>();
world.init_resource::<EditorHistory>();
world.init_resource::<SceneIo>();
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::<TerrainDesc>(entity), Some(&original));
assert!(world
.resource::<SceneIo>()
.status
.starts_with("Terrain sculpt commit failed:"));
}
#[test]
fn cancel_restores_exact_pre_stroke_descriptor() {
fn cancel_once(
mut state: ResMut<TerrainSculptState>,
mut terrains: Query<(&GlobalTransform, &mut TerrainDesc)>,
mut scene_io: ResMut<SceneIo>,
mut active_operator: ResMut<ActiveOperator>,
) {
cancel_active_stroke(
&mut state,
&mut terrains,
&mut scene_io,
&mut active_operator,
);
}
let mut app = App::new();
app.init_resource::<ActiveOperator>()
.init_resource::<EditorHistory>()
.init_resource::<SceneIo>();
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::<TerrainDesc>(entity), Some(&original));
let state = app.world().resource::<TerrainSculptState>();
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<TerrainSculptState>,
mut terrains: Query<(&GlobalTransform, &mut TerrainDesc)>,
mut scene_io: ResMut<SceneIo>,
mut active_operator: ResMut<ActiveOperator>,
) {
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::<ActiveOperator>()
.init_resource::<EditorHistory>()
.init_resource::<SceneIo>();
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::<TerrainDesc>(entity), Some(&original));
assert!(!app.world().resource::<TerrainSculptState>().active);
}
}