From 293811aecd56f685d56c43db688167057ff23729 Mon Sep 17 00:00:00 2001 From: Rbanh Date: Sun, 12 Jul 2026 19:50:47 -0400 Subject: [PATCH] Add terrain sculpt stroke tools --- .../terrain_sculpt_tools_2026-07-12.plan.md | 25 + README.md | 2 + crates/editor/src/lib.rs | 3 + crates/editor/src/ui/viewport_chrome.rs | 135 +++- crates/editor/src/viewport/mod.rs | 2 + crates/editor/src/viewport/selection.rs | 53 +- crates/editor/src/viewport/terrain_sculpt.rs | 741 ++++++++++++++++++ docs/README.md | 3 +- docs/editor/README.md | 2 + docs/editor/evaluations/README.md | 15 + .../terrain-sculpt-tools/README.md | 31 + .../terrain-sculpt-raise-stroke.png | 3 + docs/editor/terrain.md | 16 +- 13 files changed, 1026 insertions(+), 5 deletions(-) create mode 100644 .cursor/plans/terrain_sculpt_tools_2026-07-12.plan.md create mode 100644 crates/editor/src/viewport/terrain_sculpt.rs create mode 100644 docs/editor/evaluations/README.md create mode 100644 docs/editor/evaluations/terrain-sculpt-tools/README.md create mode 100644 docs/editor/evaluations/terrain-sculpt-tools/terrain-sculpt-raise-stroke.png diff --git a/.cursor/plans/terrain_sculpt_tools_2026-07-12.plan.md b/.cursor/plans/terrain_sculpt_tools_2026-07-12.plan.md new file mode 100644 index 0000000..2e88bf7 --- /dev/null +++ b/.cursor/plans/terrain_sculpt_tools_2026-07-12.plan.md @@ -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. diff --git a/README.md b/README.md index 479d27d..eb63bf4 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/crates/editor/src/lib.rs b/crates/editor/src/lib.rs index ee184da..4bbecee 100644 --- a/crates/editor/src/lib.rs +++ b/crates/editor/src/lib.rs @@ -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) diff --git a/crates/editor/src/ui/viewport_chrome.rs b/crates/editor/src/ui/viewport_chrome.rs index 39a3519..d3c5caa 100644 --- a/crates/editor/src/ui/viewport_chrome.rs +++ b/crates/editor/src/ui/viewport_chrome.rs @@ -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, } 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::() 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::().options_open; let mut shortcuts_open = world.resource::().shortcuts_open; + let terrain_selected = world + .resource::() + .0 + .filter(|entity| world.get::(*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::().active; + if terrain_selected.is_some() || sculpt_active { + ui.separator(); + let stroking = world.resource::().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::().0 = None; + } + if sculpt_button_clicked && !stroking { + let state = &mut *world.resource_mut::(); + if state.active { + state.stop(); + } else if let Some(entity) = terrain_selected { + state.start(entity); + } + let active = state.active; + if active { + world.resource_mut::().cancel(); + world + .resource_mut::() + .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::().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::().radius; + let mut strength = world.resource::().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::(); + state.mode = mode; + state.radius = radius; + state.strength = strength; + state.clamp_settings(); + } + } + let clean_game_view = world.resource::().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::().0 = None; + } + world.resource_mut::().toolbar_rect = Some(toolbar_response.response.rect); + if options_open { scene_options_popover(world, ctx, scene_rect, &mut options_open); } diff --git a/crates/editor/src/viewport/mod.rs b/crates/editor/src/viewport/mod.rs index 8f22e99..6a19f6e 100644 --- a/crates/editor/src/viewport/mod.rs +++ b/crates/editor/src/viewport/mod.rs @@ -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; diff --git a/crates/editor/src/viewport/selection.rs b/crates/editor/src/viewport/selection.rs index d244a71..947b9ad 100644 --- a/crates/editor/src/viewport/selection.rs +++ b/crates/editor/src/viewport/selection.rs @@ -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); @@ -49,6 +51,8 @@ struct PickTargetQueries<'w, 's> { parents: Query<'w, 's, &'static ChildOf>, editor_only: Query<'w, 's, (), With>, material_drop: Option>, + 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>, ) -> 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, mut selected: ResMut, @@ -228,7 +251,11 @@ fn cycle_overlapping_viewport_pick( buttons: Res>, display: Res, material_drop: Option>, + terrain_sculpt: Res, ) -> 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>, display: Res, brush_mode: Res, + terrain_sculpt: Res, ) { 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::(); @@ -474,6 +502,7 @@ mod tests { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .add_systems(Update, sync_gizmo_targets); app.update(); @@ -488,4 +517,26 @@ mod tests { assert!(app.world().get::(first).is_some()); assert!(app.world().get::(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::() + .init_resource::() + .init_resource::() + .add_systems(Update, sync_gizmo_targets); + + app.update(); + assert!(app.world().get::(terrain).is_none()); + } } diff --git a/crates/editor/src/viewport/terrain_sculpt.rs b/crates/editor/src/viewport/terrain_sculpt.rs new file mode 100644 index 0000000..1dffce1 --- /dev/null +++ b/crates/editor/src/viewport/terrain_sculpt.rs @@ -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, + stroke: Option, + target: Option, + next_seed: u32, +} + +impl Default for TerrainSculptState { + fn default() -> Self { + Self { + active: false, + mode: TerrainSculptMode::Raise, + radius: 4.0, + strength: 0.35, + hover: None, + stroke: None, + target: None, + next_seed: 1, + } + } +} + +impl TerrainSculptState { + pub fn start(&mut self, target: Entity) { + self.active = true; + self.target = Some(target); + } + + pub fn stop(&mut self) { + self.active = false; + self.hover = None; + self.target = None; + } + + pub fn is_stroking(&self) -> bool { + self.stroke.is_some() + } + + pub fn clamp_settings(&mut self) { + self.radius = self.radius.clamp(MIN_RADIUS, MAX_RADIUS); + self.strength = self.strength.clamp(MIN_STRENGTH, MAX_STRENGTH); + } +} + +pub struct TerrainSculptPlugin; + +impl Plugin for TerrainSculptPlugin { + fn build(&self, app: &mut App) { + app.init_resource::().add_systems( + Update, + (terrain_sculpt_input, draw_terrain_sculpt_preview) + .chain() + .run_if(scene_tools_active), + ); + } +} + +#[allow(clippy::too_many_arguments)] +fn terrain_sculpt_input( + mut commands: Commands, + mut state: ResMut, + mut selected: ResMut, + keys: Res>, + buttons: Res>, + mut contexts: EguiContexts, + ui_state: Res, + display: Res, + cameras: Query<(&Camera, &GlobalTransform), With>, + mut terrains: Query<(&GlobalTransform, &mut TerrainDesc)>, + mut viewport_click: ResMut, + mut scene_io: ResMut, + mut active_operator: ResMut, +) -> Result { + if !state.active { + return Ok(()); + } + viewport_click.0 = None; + state.clamp_settings(); + + let Some(entity) = state.target.or(selected.0) else { + 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::().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) { + 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>, + pointer: bevy_egui::egui::Pos2, + scene_rect: bevy_egui::egui::Rect, +) -> Option { + let (camera, transform) = cameras + .iter() + .find(|(camera, _)| camera.is_active) + .or_else(|| cameras.iter().next())?; + scene_view_ray(camera, transform, pointer, scene_rect) +} + +fn terrain_ray_hit( + entity: Entity, + terrain: &TerrainDesc, + global: &GlobalTransform, + ray: Ray3d, +) -> Option { + let world_from_local = global.affine(); + let local_from_world = world_from_local.inverse(); + let origin = local_from_world.transform_point3(ray.origin); + let direction = local_from_world.transform_vector3(ray.direction.as_vec3()); + let local = ray_heightfield_intersection(terrain, origin, direction)?; + Some(TerrainHit { + entity, + local, + world: world_from_local.transform_point3(local), + world_rotation: global.rotation(), + }) +} + +fn ray_heightfield_intersection( + terrain: &TerrainDesc, + origin: Vec3, + direction: Vec3, +) -> Option { + if terrain.validate().is_err() || direction.length_squared() <= f32::EPSILON { + return None; + } + let horizontal_speed = Vec2::new(direction.x, direction.z).length(); + if horizontal_speed <= 1.0e-6 { + if direction.y.abs() <= 1.0e-6 { + return None; + } + let height = sample_height_bilinear(terrain, origin.x, origin.z)?; + let t = (height - origin.y) / direction.y; + return (t >= 0.0).then_some(origin + direction * t); + } + let half_extent = (terrain.resolution - 1) as f32 * terrain.sample_spacing * 0.5; + let (mut start, mut end) = (0.0_f32, f32::INFINITY); + for (origin_axis, direction_axis) in [(origin.x, direction.x), (origin.z, direction.z)] { + if direction_axis.abs() <= 1.0e-6 { + if origin_axis < -half_extent || origin_axis > half_extent { + return None; + } + continue; + } + let a = (-half_extent - origin_axis) / direction_axis; + let b = (half_extent - origin_axis) / direction_axis; + start = start.max(a.min(b)); + end = end.min(a.max(b)); + } + if end < start || end < 0.0 { + return None; + } + start = start.max(0.0); + + let span = (end - start).max(0.0); + let steps = ((span * horizontal_speed) / (terrain.sample_spacing * 0.4)) + .ceil() + .clamp(1.0, 8192.0) as usize; + let surface_delta = |t: f32| { + let point = origin + direction * t; + sample_height_bilinear(terrain, point.x, point.z).map(|height| point.y - height) + }; + let mut previous_t = start; + let mut previous = surface_delta(start)?; + if previous.abs() <= 1.0e-4 { + return Some(origin + direction * start); + } + for step in 1..=steps { + let t = start + span * (step as f32 / steps as f32); + let current = surface_delta(t)?; + if current.signum() != previous.signum() || current.abs() <= 1.0e-4 { + let (mut low, mut high) = (previous_t, t); + let low_sign = previous.signum(); + for _ in 0..14 { + let middle = (low + high) * 0.5; + let delta = surface_delta(middle)?; + if delta.signum() == low_sign { + low = middle; + } else { + high = middle; + } + } + let hit_t = (low + high) * 0.5; + return Some(origin + direction * hit_t); + } + previous_t = t; + previous = current; + } + None +} + +fn sample_height_bilinear(terrain: &TerrainDesc, local_x: f32, local_z: f32) -> Option { + let half_extent = (terrain.resolution - 1) as f32 * terrain.sample_spacing * 0.5; + let x = (local_x + half_extent) / terrain.sample_spacing; + let z = (local_z + half_extent) / terrain.sample_spacing; + let max = (terrain.resolution - 1) as f32; + if x < 0.0 || z < 0.0 || x > max || z > max { + return None; + } + let x0 = x.floor() as u32; + let z0 = z.floor() as u32; + let x1 = (x0 + 1).min(terrain.resolution - 1); + let z1 = (z0 + 1).min(terrain.resolution - 1); + let sample = |sx: u32, sz: u32| { + terrain.heights[(sz * terrain.resolution + sx) as usize] * terrain.height_scale + }; + let top = sample(x0, z0).lerp(sample(x1, z0), x - x0 as f32); + let bottom = sample(x0, z1).lerp(sample(x1, z1), x - x0 as f32); + Some(top.lerp(bottom, z - z0 as f32)) +} + +fn apply_dab( + terrain: &mut TerrainDesc, + center: Vec3, + radius: f32, + strength: f32, + mode: TerrainSculptMode, + flatten_height: f32, + seed: u32, +) { + let radius = radius.max(MIN_RADIUS); + let resolution = terrain.resolution; + let half_extent = (resolution - 1) as f32 * terrain.sample_spacing * 0.5; + let source = terrain.heights.clone(); + let normalized_strength = strength / terrain.height_scale.max(0.01); + for z in 0..resolution { + for x in 0..resolution { + let sample_position = Vec2::new( + x as f32 * terrain.sample_spacing - half_extent, + z as f32 * terrain.sample_spacing - half_extent, + ); + let distance = sample_position.distance(Vec2::new(center.x, center.z)); + if distance > radius { + continue; + } + let falloff = smooth_falloff(distance / radius); + let index = (z * resolution + x) as usize; + let current = source[index]; + let next = match mode { + TerrainSculptMode::Raise => current + normalized_strength * falloff, + TerrainSculptMode::Lower => current - normalized_strength * falloff, + TerrainSculptMode::Flatten => { + let target = flatten_height / terrain.height_scale.max(0.01); + current.lerp(target, (normalized_strength * falloff).clamp(0.0, 1.0)) + } + TerrainSculptMode::Smooth => { + let average = neighbor_average(&source, resolution, x, z); + current.lerp(average, (normalized_strength * falloff).clamp(0.0, 1.0)) + } + TerrainSculptMode::Noise => { + current + signed_noise(x, z, seed) * normalized_strength * falloff + } + }; + terrain.heights[index] = next; + } + } +} + +fn smooth_falloff(normalized_distance: f32) -> f32 { + let t = (1.0 - normalized_distance).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +fn neighbor_average(heights: &[f32], resolution: u32, x: u32, z: u32) -> f32 { + let mut sum = 0.0; + let mut count = 0; + for dz in -1_i32..=1 { + for dx in -1_i32..=1 { + let sx = x as i32 + dx; + let sz = z as i32 + dz; + if sx >= 0 && sz >= 0 && sx < resolution as i32 && sz < resolution as i32 { + sum += heights[(sz as u32 * resolution + sx as u32) as usize]; + count += 1; + } + } + } + sum / count as f32 +} + +fn signed_noise(x: u32, z: u32, seed: u32) -> f32 { + let mut value = x + .wrapping_mul(0x9e37_79b9) + .wrapping_add(z.wrapping_mul(0x85eb_ca6b)) + .wrapping_add(seed.wrapping_mul(0xc2b2_ae35)); + value ^= value >> 16; + value = value.wrapping_mul(0x7feb_352d); + value ^= value >> 15; + value = value.wrapping_mul(0x846c_a68b); + value ^= value >> 16; + (value as f32 / u32::MAX as f32) * 2.0 - 1.0 +} + +fn draw_terrain_sculpt_preview(state: Res, mut gizmos: Gizmos) { + if !state.active { + return; + } + let Some(hit) = state.hover else { + return; + }; + let color = match state.mode { + TerrainSculptMode::Raise => Color::srgba(0.30, 0.92, 0.58, 0.95), + TerrainSculptMode::Lower => Color::srgba(0.96, 0.36, 0.32, 0.95), + TerrainSculptMode::Flatten => Color::srgba(0.32, 0.72, 1.0, 0.95), + TerrainSculptMode::Smooth => Color::srgba(0.72, 0.56, 1.0, 0.95), + TerrainSculptMode::Noise => Color::srgba(1.0, 0.76, 0.28, 0.95), + }; + let rotation = hit.world_rotation * Quat::from_rotation_x(std::f32::consts::FRAC_PI_2); + gizmos + .circle( + Isometry3d::new(hit.world + Vec3::Y * 0.03, rotation), + state.radius, + color, + ) + .resolution(48); + gizmos.sphere( + Isometry3d::from_translation(hit.world + Vec3::Y * 0.035), + 0.06, + color, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::history::{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::() + .register_type::(); + let world = app.world_mut(); + world.init_resource::(); + world.init_resource::(); + let original = TerrainDesc::flat(9); + let entity = world + .spawn((LevelObject, ActorKind::Terrain, original.clone())) + .id(); + let mut final_terrain = original.clone(); + for x in [-2.0, 0.0, 2.0] { + apply_dab( + &mut final_terrain, + Vec3::new(x, 0.0, 0.0), + 2.5, + 0.4, + TerrainSculptMode::Raise, + 0.0, + 7, + ); + } + world.entity_mut(entity).insert(final_terrain.clone()); + 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::().undo_depth(), 1); + assert_eq!(world.get::(entity), Some(&final_terrain)); + apply_command_undo(world); + assert_eq!(world.get::(entity), Some(&original)); + apply_command_redo(world); + assert_eq!(world.get::(entity), Some(&final_terrain)); + } + + #[test] + fn cancel_restores_exact_pre_stroke_descriptor() { + fn cancel_once( + mut state: ResMut, + 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::(entity), Some(&original)); + assert!(!app.world().resource::().is_stroking()); + } +} diff --git a/docs/README.md b/docs/README.md index 9683e02..499475e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 (R1–R6) | +| [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 | diff --git a/docs/editor/README.md b/docs/editor/README.md index 131833e..7d6ea2d 100644 --- a/docs/editor/README.md +++ b/docs/editor/README.md @@ -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 | diff --git a/docs/editor/evaluations/README.md b/docs/editor/evaluations/README.md new file mode 100644 index 0000000..930a8c0 --- /dev/null +++ b/docs/editor/evaluations/README.md @@ -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/` 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. diff --git a/docs/editor/evaluations/terrain-sculpt-tools/README.md b/docs/editor/evaluations/terrain-sculpt-tools/README.md new file mode 100644 index 0000000..a209d65 --- /dev/null +++ b/docs/editor/evaluations/terrain-sculpt-tools/README.md @@ -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. diff --git a/docs/editor/evaluations/terrain-sculpt-tools/terrain-sculpt-raise-stroke.png b/docs/editor/evaluations/terrain-sculpt-tools/terrain-sculpt-raise-stroke.png new file mode 100644 index 0000000..0afa560 --- /dev/null +++ b/docs/editor/evaluations/terrain-sculpt-tools/terrain-sculpt-raise-stroke.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:45c4addef14ad869f8b0380312aeef61c1edd1876bbec5795b5f928850562b24 +size 762379 diff --git a/docs/editor/terrain.md b/docs/editor/terrain.md index 2b9f931..cf6d33e 100644 --- a/docs/editor/terrain.md +++ b/docs/editor/terrain.md @@ -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