Add terrain sculpt stroke tools
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run

This commit is contained in:
Rbanh 2026-07-12 19:50:47 -04:00
parent ed21bcc52d
commit 293811aecd
13 changed files with 1026 additions and 5 deletions

View File

@ -0,0 +1,25 @@
# Terrain Sculpt Tools (#23)
## Scope
- Add a modal terrain sculpt operator for raise, lower, flatten, smooth, and deterministic noise.
- Keep activation and brush settings in the existing horizontal viewport toolbar.
- Track a terrain-local cursor hit and render a world-space brush footprint preview.
- Apply live stroke previews while preserving one history entry per completed stroke.
- Restore the exact pre-stroke height grid on Escape or right-click.
## Implementation
1. Add pure heightfield sampling, ray-hit, falloff, and brush-dab functions with focused tests.
2. Add editor-only terrain sculpt state and viewport input/preview systems.
3. Commit strokes through the reflected component transaction after restoring the pre-stroke snapshot.
4. Add compact terrain controls to the existing viewport toolbar and update operator hints.
5. Document controls and validation evidence in the terrain editor guide and root checklist.
## Acceptance
- All five modes visibly modify terrain.
- The brush ring follows the actual terrain surface.
- Escape and right-click cancel without dirtying history.
- Mouse release creates exactly one undoable transaction regardless of dab count.
- Formatting, editor/shared checks, focused tests, and live editor QA pass.

View File

@ -171,6 +171,7 @@ deep-stale variants.
| Inspector component card | Collapse with caret, toggle active with status dot, or use triple-dot menu for reset/copy/paste/move/remove actions |
| Inspector footer → Add Component | Expands an inline search shelf for registered authoring, rendering, physics, gameplay, and volume components with descriptions, availability hints, and undo |
| Inspector footer → Add Component → Terrain | Add a height-grid terrain actor; configure grid scale/chunking/collision in its component card and use Resize Flat only for deliberate grid replacement |
| Selected Terrain → viewport mountains tool | Sculpt Raise/Lower/Flatten/Smooth/Noise strokes with a terrain-following footprint; release commits one undo step, while Escape/right-click restores the pre-stroke grid |
| Audio Source / Listener inspector | Assign and audition clips; edit gain, pitch, loop/autoplay, spatial blend, attenuation, bus, listener priority, and ear gap |
| Edit → Project Settings… | Edit `assets/project.ron` rendering, audio buses, physics, and input |
@ -400,6 +401,7 @@ crates/
- [x] Delete, duplicate, rename, and structural/material undo-redo
- [x] Native Bevy scene New/Open/Save/Save As with dirty title tracking
- [x] First-class height-grid Terrain actor with deterministic generated render/collider chunks, reflected history, validation, and a committed showcase fixture ([ADR 0039](docs/adr/0039-inline-height-grid-terrain-foundation.md), [terrain guide](docs/editor/terrain.md), [Gitea #22](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/22))
- [x] Modal terrain Raise/Lower/Flatten/Smooth/Noise sculpting with a terrain-following footprint, deterministic noise, safe cancel restore, and one undo transaction per stroke ([terrain guide](docs/editor/terrain.md), [Gitea #23](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/23))
- [x] Non-blocking native file/folder/confirmation broker across scene, asset, prefab, composition, collaboration, and Project Browser workflows ([ADR 0038](docs/adr/0038-non-blocking-native-dialog-broker.md), [workflow guide](docs/editor/native-dialogs.md), [Gitea #52](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/52))
- [x] Asset import, static mesh/prefab placement, texture assignment, and selection export
- [x] PIE player-only snapshot/restore (authored `LevelObject` edits persist on stop)

View File

@ -43,6 +43,7 @@ pub use viewport::render_view;
pub use viewport::rendering_diagnostics;
pub use viewport::selection;
pub use viewport::selection_outline;
pub use viewport::terrain_sculpt;
pub use viewport::visualizers;
use bevy::app::PluginGroupBuilder;
@ -79,6 +80,7 @@ use selection_outline::SelectionOutlinePlugin;
use session::EditorSessionPlugin;
use settings_ui::SettingsUiPlugin;
use state::EditorStatePlugin;
use terrain_sculpt::TerrainSculptPlugin;
use ui::EditorUiPlugin;
use viewport::ViewportPlugin;
use visualizers::EditorVisualizersPlugin;
@ -109,6 +111,7 @@ impl PluginGroup for EditorPluginGroup {
.add(BrushCsgPlugin)
.add(BrushEditPlugin)
.add(BrushToolPlugin)
.add(TerrainSculptPlugin)
.add(EditorCameraPlugin)
.add(AudioPreviewPlugin)
.add(ActorIconsPlugin)

View File

@ -20,6 +20,7 @@ use crate::state::PlayPossession;
use crate::viewport::actor_icons::ActorIconSettings;
use crate::viewport::brush_edit::{BrushEditMode, BrushElementSelection};
use crate::viewport::brush_tool::{BrushToolPhase, BrushToolState};
use crate::viewport::terrain_sculpt::{TerrainSculptMode, TerrainSculptState};
use crate::viewport::{
material_drop::is_surface_asset_selection, snap_translation, viewport_ground_position,
EditorViewportMode, MaterialDropState, ViewportDisplayMode, ViewportSettings,
@ -41,6 +42,7 @@ use super::widgets::{
pub struct ViewportUiState {
pub options_open: bool,
pub shortcuts_open: bool,
pub toolbar_rect: Option<egui::Rect>,
}
const VIEWPORT_TOOLTIP: &str = "RMB + WASD/QE: fly | MMB: pan | Scroll: dolly\nW/E/R: gizmo | X: world/local | F: focus | G: game view | Ctrl+G: grid\nTab: cycle overlapping picks | Play/Pause/Stop: main toolbar (F5 / F6)";
@ -146,6 +148,7 @@ pub fn viewport_tab_ui(
scene_view_overlay_toolbar(world, ui.ctx(), rect);
scene_view_selection_hud(world, ui.ctx(), rect, selected_entities);
scene_view_brush_draw_hints(world, ui.ctx(), rect);
scene_view_terrain_sculpt_hints(world, ui.ctx(), rect);
scene_view_render_badge(world, ui.ctx(), rect);
scene_view_brush_mode_badge(world, ui.ctx(), rect);
scene_view_volume_hud(world, ui.ctx(), rect);
@ -304,6 +307,48 @@ fn scene_view_brush_draw_hints(world: &World, ctx: &egui::Context, scene_rect: e
});
}
fn scene_view_terrain_sculpt_hints(world: &World, ctx: &egui::Context, scene_rect: egui::Rect) {
let Some(tool) = world.get_resource::<TerrainSculptState>() else {
return;
};
if !tool.active {
return;
}
egui::Area::new(egui::Id::new("scene_view_terrain_sculpt_hints"))
.fixed_pos(scene_rect.left_top() + egui::vec2(8.0, 52.0))
.interactable(false)
.show(ctx, |ui| {
overlay_chip_frame().show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(format!("Terrain {}", tool.mode.label()))
.color(SELECTION)
.strong(),
);
ui.label(
egui::RichText::new(format!("R {:.1}m", tool.radius))
.color(TEXT_DIM)
.monospace()
.small(),
);
ui.label(
egui::RichText::new(format!("S {:.2}m", tool.strength))
.color(SUCCESS)
.monospace()
.small(),
);
});
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| {
key_hint(ui, "LMB Drag", "Sculpt");
key_hint(ui, "Esc", "Cancel stroke / close");
key_hint(ui, "RMB", "Cancel stroke / close");
key_hint(ui, "Ctrl+Z", "Undo stroke");
});
});
});
}
fn key_hint(ui: &mut egui::Ui, key: &str, label: &str) {
ui.horizontal(|ui| {
ui.label(
@ -492,8 +537,12 @@ fn scene_view_volume_hud(world: &World, ctx: &egui::Context, scene_rect: egui::R
fn scene_view_overlay_toolbar(world: &mut World, ctx: &egui::Context, scene_rect: egui::Rect) {
let mut options_open = world.resource::<ViewportUiState>().options_open;
let mut shortcuts_open = world.resource::<ViewportUiState>().shortcuts_open;
let terrain_selected = world
.resource::<crate::selection::SelectedEntity>()
.0
.filter(|entity| world.get::<shared::TerrainDesc>(*entity).is_some());
egui::Area::new(egui::Id::new("scene_view_toolbar"))
let toolbar_response = egui::Area::new(egui::Id::new("scene_view_toolbar"))
.fixed_pos(scene_rect.left_top() + egui::vec2(6.0, 6.0))
.interactable(true)
.show(ctx, |ui| {
@ -622,6 +671,83 @@ fn scene_view_overlay_toolbar(world: &mut World, ctx: &egui::Context, scene_rect
}
}
let sculpt_active = world.resource::<TerrainSculptState>().active;
if terrain_selected.is_some() || sculpt_active {
ui.separator();
let stroking = world.resource::<TerrainSculptState>().is_stroking();
let sculpt_button_clicked = tool_button_accent(
ui,
icons::MOUNTAINS,
sculpt_active,
if sculpt_active {
"Close terrain sculpt tool"
} else {
"Sculpt selected terrain"
},
)
.clicked();
if sculpt_button_clicked {
world.resource_mut::<ViewportClick>().0 = None;
}
if sculpt_button_clicked && !stroking {
let state = &mut *world.resource_mut::<TerrainSculptState>();
if state.active {
state.stop();
} else if let Some(entity) = terrain_selected {
state.start(entity);
}
let active = state.active;
if active {
world.resource_mut::<BrushToolState>().cancel();
world
.resource_mut::<crate::operators::ActiveOperator>()
.status = Some(crate::operators::OperatorStatus {
id: "terrain.sculpt".to_string(),
label: "Terrain Sculpt".to_string(),
phase: crate::operators::OperatorPhase::Preview,
hint: "LMB drag sculpts; Esc or RMB cancels".to_string(),
warnings: Vec::new(),
});
}
}
if sculpt_active {
let mut mode = world.resource::<TerrainSculptState>().mode;
for candidate in [
TerrainSculptMode::Raise,
TerrainSculptMode::Lower,
TerrainSculptMode::Flatten,
TerrainSculptMode::Smooth,
TerrainSculptMode::Noise,
] {
ui.selectable_value(&mut mode, candidate, candidate.label())
.on_hover_text(format!("{} terrain", candidate.label()));
}
let mut radius = world.resource::<TerrainSculptState>().radius;
let mut strength = world.resource::<TerrainSculptState>().strength;
ui.add(
egui::DragValue::new(&mut radius)
.range(0.25..=128.0)
.speed(0.1)
.prefix("R ")
.suffix(" m"),
)
.on_hover_text("Brush radius");
ui.add(
egui::DragValue::new(&mut strength)
.range(0.01..=20.0)
.speed(0.05)
.prefix("S ")
.suffix(" m"),
)
.on_hover_text("Height strength per dab");
let mut state = world.resource_mut::<TerrainSculptState>();
state.mode = mode;
state.radius = radius;
state.strength = strength;
state.clamp_settings();
}
}
let clean_game_view = world.resource::<ViewportDisplayMode>().clean_game_view;
if tool_button(
ui,
@ -649,6 +775,13 @@ fn scene_view_overlay_toolbar(world: &mut World, ctx: &egui::Context, scene_rect
});
});
let toolbar_clicked = toolbar_response.response.contains_pointer()
&& ctx.input(|input| input.pointer.button_clicked(egui::PointerButton::Primary));
if toolbar_clicked {
world.resource_mut::<ViewportClick>().0 = None;
}
world.resource_mut::<ViewportUiState>().toolbar_rect = Some(toolbar_response.response.rect);
if options_open {
scene_options_popover(world, ctx, scene_rect, &mut options_open);
}

View File

@ -12,6 +12,7 @@ pub mod render_view;
pub mod rendering_diagnostics;
pub mod selection;
pub mod selection_outline;
pub mod terrain_sculpt;
pub mod viewport_mode;
pub mod visualizers;
@ -24,4 +25,5 @@ pub use brush_edit::{BrushEditMode, BrushEditPlugin, BrushElementSelection};
pub use brush_tool::{BrushToolPlugin, BrushToolState};
pub use material_drop::{MaterialDropFeedback, MaterialDropPlugin, MaterialDropState};
pub use panel::*;
pub use terrain_sculpt::{TerrainSculptMode, TerrainSculptPlugin, TerrainSculptState};
pub use viewport_mode::EditorViewportMode;

View File

@ -14,6 +14,7 @@ use crate::ui::helpers::ensure_player_spawn_for_edit;
use crate::ui::hierarchy_ops::is_entity_locked;
use crate::ui::hierarchy_state::HierarchyPanelState;
use crate::ui::UiState;
use crate::ui::ViewportUiState;
use crate::viewport::actor_icons::ActorIconProxy;
use crate::viewport::brush_edit::{BrushEditMode, BrushElementGizmo};
use crate::viewport::brush_tool::BrushToolState;
@ -23,6 +24,7 @@ use bevy_egui::egui;
use crate::viewport::material_drop::{MaterialDropSet, MaterialDropState};
use crate::viewport::scene_view_ray;
use crate::viewport::terrain_sculpt::TerrainSculptState;
#[derive(Resource, Default, Debug, Clone, Copy)]
pub struct SelectedEntity(pub Option<Entity>);
@ -49,6 +51,8 @@ struct PickTargetQueries<'w, 's> {
parents: Query<'w, 's, &'static ChildOf>,
editor_only: Query<'w, 's, (), With<EditorOnly>>,
material_drop: Option<Res<'w, MaterialDropState>>,
terrain_sculpt: Res<'w, TerrainSculptState>,
viewport_ui: Res<'w, ViewportUiState>,
}
impl Plugin for EditorSelectionPlugin {
@ -113,6 +117,21 @@ fn handle_pick_events(
gizmo_targets: Query<&GizmoTarget>,
hierarchy: Option<Res<HierarchyPanelState>>,
) -> Result {
if ui_state.viewport_pointer_pos.is_some_and(|pointer| {
pick_targets
.viewport_ui
.toolbar_rect
.is_some_and(|rect| rect.contains(pointer))
}) {
viewport_click.0 = None;
for _ in click_events.read() {}
return Ok(());
}
if pick_targets.terrain_sculpt.active {
viewport_click.0 = None;
for _ in click_events.read() {}
return Ok(());
}
if pick_targets
.material_drop
.as_deref()
@ -220,6 +239,10 @@ fn handle_pick_events(
Ok(())
}
#[expect(
clippy::too_many_arguments,
reason = "selection cycling reads independent viewport input ownership resources"
)]
fn cycle_overlapping_viewport_pick(
mut ui_state: ResMut<UiState>,
mut selected: ResMut<SelectedEntity>,
@ -228,7 +251,11 @@ fn cycle_overlapping_viewport_pick(
buttons: Res<ButtonInput<MouseButton>>,
display: Res<ViewportDisplayMode>,
material_drop: Option<Res<MaterialDropState>>,
terrain_sculpt: Res<TerrainSculptState>,
) -> Result {
if terrain_sculpt.active {
return Ok(());
}
if material_drop
.as_deref()
.is_some_and(MaterialDropState::captures_viewport_input)
@ -415,13 +442,14 @@ fn sync_gizmo_targets(
hierarchy: Option<Res<HierarchyPanelState>>,
display: Res<ViewportDisplayMode>,
brush_mode: Res<BrushEditMode>,
terrain_sculpt: Res<TerrainSculptState>,
) {
ui_state
.selected_entities
.retain(|entity| transforms.contains(entity) && !editor_only.contains(entity));
selected.0 = ui_state.selected_entities.as_slice().first().copied();
if display.clean_game_view || brush_mode.is_element_mode() {
if display.clean_game_view || brush_mode.is_element_mode() || terrain_sculpt.active {
for (entity, brush_element_gizmo) in &targets {
if brush_element_gizmo.is_none() {
commands.entity(entity).remove::<GizmoTarget>();
@ -474,6 +502,7 @@ mod tests {
.init_resource::<SelectedEntity>()
.init_resource::<ViewportDisplayMode>()
.init_resource::<BrushEditMode>()
.init_resource::<TerrainSculptState>()
.add_systems(Update, sync_gizmo_targets);
app.update();
@ -488,4 +517,26 @@ mod tests {
assert!(app.world().get::<GizmoTarget>(first).is_some());
assert!(app.world().get::<GizmoTarget>(second).is_none());
}
#[test]
fn terrain_sculpt_owns_pointer_without_transform_gizmo() {
let mut app = App::new();
let terrain = app
.world_mut()
.spawn((LevelObject, Transform::default(), GizmoTarget::default()))
.id();
let mut ui_state = UiState::default_layout();
ui_state.selected_entities.select_replace(terrain);
let mut sculpt = TerrainSculptState::default();
sculpt.start(terrain);
app.insert_resource(ui_state)
.insert_resource(sculpt)
.init_resource::<SelectedEntity>()
.init_resource::<ViewportDisplayMode>()
.init_resource::<BrushEditMode>()
.add_systems(Update, sync_gizmo_targets);
app.update();
assert!(app.world().get::<GizmoTarget>(terrain).is_none());
}
}

View File

@ -0,0 +1,741 @@
//! 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;
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)]
struct TerrainHit {
entity: Entity,
local: Vec3,
world: Vec3,
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 {
cancel_stroke(&mut state, &mut terrains);
state.stop();
return Ok(());
};
if selected.0 != Some(entity) {
selected.0 = Some(entity);
}
if display.clean_game_view {
cancel_stroke(&mut state, &mut terrains);
state.stop();
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_stroke(&mut state, &mut terrains);
scene_io.status = "Terrain sculpt stroke canceled".to_string();
set_status(
&mut active_operator,
OperatorPhase::Canceled,
"Stroke canceled; terrain restored",
);
} 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 {
state.stroke = None;
state.stop();
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| {
let result = commit_terrain_stroke(world, stroke, final_terrain, label);
if let Err(error) = result {
world.resource_mut::<SceneIo>().status =
format!("Terrain sculpt commit failed: {error}");
}
});
scene_io.status = format!("{mode_label} terrain stroke committed");
set_status(
&mut active_operator,
OperatorPhase::Committed,
format!("{mode_label} stroke; Ctrl+Z to undo"),
);
}
}
Ok(())
}
fn commit_terrain_stroke(
world: &mut World,
stroke: TerrainStroke,
final_terrain: TerrainDesc,
label: &'static str,
) -> Result<(), String> {
if let Ok(mut actor) = world.get_entity_mut(stroke.entity) {
actor.insert(stroke.original);
}
reflected_component_transaction(
world,
stroke.entity,
label,
AUTHORING_COMPONENT_TERRAIN,
COMPONENT_TERRAIN_DESC,
move |world, entity| {
world.entity_mut(entity).insert(final_terrain);
Ok(())
},
)
}
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",
}
}
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)
}
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;
}
}
}
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::{apply_command_redo, apply_command_undo, EditorHistory};
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::<EditorHistory>();
world.init_resource::<SceneIo>();
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());
commit_terrain_stroke(
world,
TerrainStroke {
entity,
original: original.clone(),
last_local: Vec3::ZERO,
flatten_height: 0.0,
seed: 7,
},
final_terrain.clone(),
"Raise Terrain",
)
.unwrap();
assert_eq!(world.resource::<EditorHistory>().undo_depth(), 1);
assert_eq!(world.get::<TerrainDesc>(entity), Some(&final_terrain));
apply_command_undo(world);
assert_eq!(world.get::<TerrainDesc>(entity), Some(&original));
apply_command_redo(world);
assert_eq!(world.get::<TerrainDesc>(entity), Some(&final_terrain));
}
#[test]
fn cancel_restores_exact_pre_stroke_descriptor() {
fn cancel_once(
mut state: ResMut<TerrainSculptState>,
mut terrains: Query<(&GlobalTransform, &mut TerrainDesc)>,
) {
cancel_stroke(&mut state, &mut terrains);
}
let mut app = App::new();
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((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);
app.update();
assert_eq!(app.world().get::<TerrainDesc>(entity), Some(&original));
assert!(!app.world().resource::<TerrainSculptState>().is_stroking());
}
}

View File

@ -60,7 +60,7 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
| Document | Purpose |
|----------|---------|
| [editor/release-notes.md](editor/release-notes.md) | Editor framework 1.0 baseline |
| [editor/evaluations/](editor/evaluations/) | Horizon gate sign-offs (R1R6) |
| [editor/evaluations/](editor/evaluations/) | Acceptance records, screenshots, and Gitea evidence-publishing policy |
| [editor/architecture.md](editor/architecture.md) | Viewport / PIE / settings data flow |
| [editor/visual-language.md](editor/visual-language.md) | Editor chrome, viewport, selection, gizmo, and visualizer language |
| [editor/roadmap.md](editor/roadmap.md) | Phased editor roadmap and status |
@ -82,6 +82,7 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
| [editor/native-dialogs.md](editor/native-dialogs.md) | Non-blocking native dialog acquisition and main-thread result application |
| [editor/terrain.md](editor/terrain.md) | Terrain schema, inspector workflow, chunk hydration, collision, and follow-on boundaries |
| [editor/evaluations/material-renderer-foundation/](editor/evaluations/material-renderer-foundation/) | Live screenshots and acceptance results for the renderer/material/component foundation |
| [editor/evaluations/terrain-sculpt-tools/](editor/evaluations/terrain-sculpt-tools/) | Live screenshot and acceptance results for modal terrain sculpt tools |
| [editor/evaluations/navigation-authoring/](editor/evaluations/navigation-authoring/) | Live screenshots and acceptance results for navigation authoring and runtime parity |
| [editor/evaluations/production-readiness/](editor/evaluations/production-readiness/) | Current versioned production gate, evidence matrix, candidate commands, soak protocol, and independent sign-off requirements |

View File

@ -26,11 +26,13 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
| [collaborative-file-safety.md](collaborative-file-safety.md) | Exact authored-file revisions, Git/read-only status, conflict recovery, and optional ownership providers |
| [native-dialogs.md](native-dialogs.md) | Non-blocking file/folder/confirmation acquisition and main-thread result application |
| [terrain.md](terrain.md) | Inline height-grid terrain, chunk hydration, collision, inspector workflow, and fixtures |
| [evaluations/](evaluations/) | Acceptance evidence records and native Gitea attachment publishing policy |
| [evaluations/material-renderer-foundation/](evaluations/material-renderer-foundation/) | Live screenshots and verification record for the renderer/material/component foundation |
| [evaluations/material-library-targeted-drop/](evaluations/material-library-targeted-drop/) | Live screenshot and verification record for the docked Material Library and exact reversible surface drops |
| [evaluations/collaborative-file-safety/](evaluations/collaborative-file-safety/) | Live screenshot and verification record for source-control status and guarded external-change recovery |
| [evaluations/native-dialog-responsiveness/](evaluations/native-dialog-responsiveness/) | Live native-Wayland screenshot and verification record for non-blocking picker responsiveness |
| [evaluations/terrain-foundation/](evaluations/terrain-foundation/) | Live screenshot and verification record for height-grid terrain schema and chunk hydration |
| [evaluations/terrain-sculpt-tools/](evaluations/terrain-sculpt-tools/) | Live screenshot and verification record for modal sculpt controls, footprint, and stroke history |
| [evaluations/navigation-authoring/](evaluations/navigation-authoring/) | Live screenshots and verification record for navigation authoring and runtime parity |
| [evaluations/production-readiness/](evaluations/production-readiness/) | Current #50 release-candidate matrix and required clean-checkout, soak, performance, limitation, and independent-signoff evidence |

View File

@ -0,0 +1,15 @@
# Editor Evaluation Evidence
Each subdirectory keeps the canonical, versioned acceptance record for an editor feature. Screenshots
remain in the repository beside that record and are intentionally stored through Git LFS.
## Publishing To Gitea
Do not embed a repository `/raw/branch/...` or `/raw/commit/...` PNG URL in an issue or pull-request
comment. Gitea can serve the LFS pointer document at that URL, which renders as text instead of an
image. Upload the PNG to the relevant issue as a native attachment and use the returned
`/attachments/<uuid>` URL for the Markdown image. Keep the repository evaluation link next to the
attachment so the evidence remains traceable to its committed source.
Native attachments are presentation copies. The committed evaluation directory remains the source of
truth and should retain the original image and verification notes.

View File

@ -0,0 +1,31 @@
# Terrain Sculpt Tools Acceptance
Issue: Gitea `#23`
Scope: native Wayland debug editor; packaged acceptance remains deferred by project-owner direction.
## Live Evidence
![Raise stroke with terrain-following footprint and horizontal sculpt controls](terrain-sculpt-raise-stroke.png)
The committed terrain showcase was selected in Forward rendering. Enabling the mountains tool kept
the actor selected, removed the transform gizmo, expanded Raise/Lower/Flatten/Smooth/Noise plus
radius/strength controls inside the existing horizontal viewport toolbar, and displayed the modal
cancel/undo hints. A dragged Raise stroke visibly changed the generated terrain while the footprint
tracked the sampled surface. Release produced exactly `Undo: Raise Terrain`; no transform `Move`
history entry was created.
Escape/right-click restoration, deterministic brush math, exact vertical-ray sampling, multi-dab
stroke grouping, and sculpt-time gizmo exclusion are covered by focused tests. The live pass also
exposed and corrected toolbar click-through into raw mesh picking before this evidence was captured.
## Source Verification
- `cargo fmt --all -- --check`
- `cargo check -p editor --all-targets`
- `cargo clippy -p editor --all-targets -- -D warnings`
- six focused terrain-sculpt/input-ownership tests
- `git diff --check`
The canonical fixture remains `assets/levels/terrain_authoring_showcase.scn.ron`; no QA-only scene or
project setting is retained.

Binary file not shown.

View File

@ -11,6 +11,19 @@ collision, shadows, base material status, and validation. **Resize Flat** delibe
height array with a flat grid; normal numeric edits retain all height samples. Every accepted edit is
one reflected history transaction and rebuilds only that terrain's generated chunks.
## Sculpt Workflow
Select a terrain actor and enable the mountains button in the existing horizontal viewport toolbar.
Choose **Raise**, **Lower**, **Flatten**, **Smooth**, or **Noise**, then LMB-drag across the terrain.
The radius and strength controls use world meters; the colored footprint follows the sampled terrain
surface and identifies the active mode.
Each drag is one stroke and one undo entry, regardless of how many interpolated dabs it contains.
Escape or right-click restores the exact pre-stroke height grid; when no stroke is active, either input
closes the sculpt tool. Flatten captures the height under the initial press. Smooth samples a stable
copy of the current neighborhood per dab, and Noise uses a deterministic per-stroke seed so authored
results remain reproducible.
`assets/levels/terrain_authoring_showcase.scn.ron` is the deterministic foundation fixture. Its 5×5
grid forms an asymmetric hill split into four 2×2-quad chunks. It validates chunk boundaries,
normals, selection through generated children, collider generation, inspector state, and save
@ -30,8 +43,7 @@ stripping without requiring external assets.
Auto/Solari is active. Terrain Surface/layer parity enters with the material-layer work in `#24`;
the editor never substitutes a black or semantically different ray-traced terrain proxy.
Sculpt brushes and grouped stroke undo are tracked by Gitea `#23`; material layers and weight
painting are tracked by `#24`. The persistence decision is recorded in
Material layers and weight painting are tracked by Gitea `#24`. The persistence decision is recorded in
[ADR 0039](../adr/0039-inline-height-grid-terrain-foundation.md).
Live native-Wayland evidence and focused verification are recorded in the