1006 lines
32 KiB
Rust
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);
|
|
}
|
|
}
|