Add terrain sculpt stroke tools
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run
This commit is contained in:
parent
ed21bcc52d
commit
293811aecd
25
.cursor/plans/terrain_sculpt_tools_2026-07-12.plan.md
Normal file
25
.cursor/plans/terrain_sculpt_tools_2026-07-12.plan.md
Normal 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.
|
||||||
@ -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 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 | 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 |
|
| 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 |
|
| 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 |
|
| 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] Delete, duplicate, rename, and structural/material undo-redo
|
||||||
- [x] Native Bevy scene New/Open/Save/Save As with dirty title tracking
|
- [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] 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] 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] Asset import, static mesh/prefab placement, texture assignment, and selection export
|
||||||
- [x] PIE player-only snapshot/restore (authored `LevelObject` edits persist on stop)
|
- [x] PIE player-only snapshot/restore (authored `LevelObject` edits persist on stop)
|
||||||
|
|||||||
@ -43,6 +43,7 @@ pub use viewport::render_view;
|
|||||||
pub use viewport::rendering_diagnostics;
|
pub use viewport::rendering_diagnostics;
|
||||||
pub use viewport::selection;
|
pub use viewport::selection;
|
||||||
pub use viewport::selection_outline;
|
pub use viewport::selection_outline;
|
||||||
|
pub use viewport::terrain_sculpt;
|
||||||
pub use viewport::visualizers;
|
pub use viewport::visualizers;
|
||||||
|
|
||||||
use bevy::app::PluginGroupBuilder;
|
use bevy::app::PluginGroupBuilder;
|
||||||
@ -79,6 +80,7 @@ use selection_outline::SelectionOutlinePlugin;
|
|||||||
use session::EditorSessionPlugin;
|
use session::EditorSessionPlugin;
|
||||||
use settings_ui::SettingsUiPlugin;
|
use settings_ui::SettingsUiPlugin;
|
||||||
use state::EditorStatePlugin;
|
use state::EditorStatePlugin;
|
||||||
|
use terrain_sculpt::TerrainSculptPlugin;
|
||||||
use ui::EditorUiPlugin;
|
use ui::EditorUiPlugin;
|
||||||
use viewport::ViewportPlugin;
|
use viewport::ViewportPlugin;
|
||||||
use visualizers::EditorVisualizersPlugin;
|
use visualizers::EditorVisualizersPlugin;
|
||||||
@ -109,6 +111,7 @@ impl PluginGroup for EditorPluginGroup {
|
|||||||
.add(BrushCsgPlugin)
|
.add(BrushCsgPlugin)
|
||||||
.add(BrushEditPlugin)
|
.add(BrushEditPlugin)
|
||||||
.add(BrushToolPlugin)
|
.add(BrushToolPlugin)
|
||||||
|
.add(TerrainSculptPlugin)
|
||||||
.add(EditorCameraPlugin)
|
.add(EditorCameraPlugin)
|
||||||
.add(AudioPreviewPlugin)
|
.add(AudioPreviewPlugin)
|
||||||
.add(ActorIconsPlugin)
|
.add(ActorIconsPlugin)
|
||||||
|
|||||||
@ -20,6 +20,7 @@ use crate::state::PlayPossession;
|
|||||||
use crate::viewport::actor_icons::ActorIconSettings;
|
use crate::viewport::actor_icons::ActorIconSettings;
|
||||||
use crate::viewport::brush_edit::{BrushEditMode, BrushElementSelection};
|
use crate::viewport::brush_edit::{BrushEditMode, BrushElementSelection};
|
||||||
use crate::viewport::brush_tool::{BrushToolPhase, BrushToolState};
|
use crate::viewport::brush_tool::{BrushToolPhase, BrushToolState};
|
||||||
|
use crate::viewport::terrain_sculpt::{TerrainSculptMode, TerrainSculptState};
|
||||||
use crate::viewport::{
|
use crate::viewport::{
|
||||||
material_drop::is_surface_asset_selection, snap_translation, viewport_ground_position,
|
material_drop::is_surface_asset_selection, snap_translation, viewport_ground_position,
|
||||||
EditorViewportMode, MaterialDropState, ViewportDisplayMode, ViewportSettings,
|
EditorViewportMode, MaterialDropState, ViewportDisplayMode, ViewportSettings,
|
||||||
@ -41,6 +42,7 @@ use super::widgets::{
|
|||||||
pub struct ViewportUiState {
|
pub struct ViewportUiState {
|
||||||
pub options_open: bool,
|
pub options_open: bool,
|
||||||
pub shortcuts_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)";
|
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_overlay_toolbar(world, ui.ctx(), rect);
|
||||||
scene_view_selection_hud(world, ui.ctx(), rect, selected_entities);
|
scene_view_selection_hud(world, ui.ctx(), rect, selected_entities);
|
||||||
scene_view_brush_draw_hints(world, ui.ctx(), rect);
|
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_render_badge(world, ui.ctx(), rect);
|
||||||
scene_view_brush_mode_badge(world, ui.ctx(), rect);
|
scene_view_brush_mode_badge(world, ui.ctx(), rect);
|
||||||
scene_view_volume_hud(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) {
|
fn key_hint(ui: &mut egui::Ui, key: &str, label: &str) {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label(
|
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) {
|
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 options_open = world.resource::<ViewportUiState>().options_open;
|
||||||
let mut shortcuts_open = world.resource::<ViewportUiState>().shortcuts_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))
|
.fixed_pos(scene_rect.left_top() + egui::vec2(6.0, 6.0))
|
||||||
.interactable(true)
|
.interactable(true)
|
||||||
.show(ctx, |ui| {
|
.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;
|
let clean_game_view = world.resource::<ViewportDisplayMode>().clean_game_view;
|
||||||
if tool_button(
|
if tool_button(
|
||||||
ui,
|
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 {
|
if options_open {
|
||||||
scene_options_popover(world, ctx, scene_rect, &mut options_open);
|
scene_options_popover(world, ctx, scene_rect, &mut options_open);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,7 @@ pub mod render_view;
|
|||||||
pub mod rendering_diagnostics;
|
pub mod rendering_diagnostics;
|
||||||
pub mod selection;
|
pub mod selection;
|
||||||
pub mod selection_outline;
|
pub mod selection_outline;
|
||||||
|
pub mod terrain_sculpt;
|
||||||
pub mod viewport_mode;
|
pub mod viewport_mode;
|
||||||
pub mod visualizers;
|
pub mod visualizers;
|
||||||
|
|
||||||
@ -24,4 +25,5 @@ pub use brush_edit::{BrushEditMode, BrushEditPlugin, BrushElementSelection};
|
|||||||
pub use brush_tool::{BrushToolPlugin, BrushToolState};
|
pub use brush_tool::{BrushToolPlugin, BrushToolState};
|
||||||
pub use material_drop::{MaterialDropFeedback, MaterialDropPlugin, MaterialDropState};
|
pub use material_drop::{MaterialDropFeedback, MaterialDropPlugin, MaterialDropState};
|
||||||
pub use panel::*;
|
pub use panel::*;
|
||||||
|
pub use terrain_sculpt::{TerrainSculptMode, TerrainSculptPlugin, TerrainSculptState};
|
||||||
pub use viewport_mode::EditorViewportMode;
|
pub use viewport_mode::EditorViewportMode;
|
||||||
|
|||||||
@ -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_ops::is_entity_locked;
|
||||||
use crate::ui::hierarchy_state::HierarchyPanelState;
|
use crate::ui::hierarchy_state::HierarchyPanelState;
|
||||||
use crate::ui::UiState;
|
use crate::ui::UiState;
|
||||||
|
use crate::ui::ViewportUiState;
|
||||||
use crate::viewport::actor_icons::ActorIconProxy;
|
use crate::viewport::actor_icons::ActorIconProxy;
|
||||||
use crate::viewport::brush_edit::{BrushEditMode, BrushElementGizmo};
|
use crate::viewport::brush_edit::{BrushEditMode, BrushElementGizmo};
|
||||||
use crate::viewport::brush_tool::BrushToolState;
|
use crate::viewport::brush_tool::BrushToolState;
|
||||||
@ -23,6 +24,7 @@ use bevy_egui::egui;
|
|||||||
|
|
||||||
use crate::viewport::material_drop::{MaterialDropSet, MaterialDropState};
|
use crate::viewport::material_drop::{MaterialDropSet, MaterialDropState};
|
||||||
use crate::viewport::scene_view_ray;
|
use crate::viewport::scene_view_ray;
|
||||||
|
use crate::viewport::terrain_sculpt::TerrainSculptState;
|
||||||
|
|
||||||
#[derive(Resource, Default, Debug, Clone, Copy)]
|
#[derive(Resource, Default, Debug, Clone, Copy)]
|
||||||
pub struct SelectedEntity(pub Option<Entity>);
|
pub struct SelectedEntity(pub Option<Entity>);
|
||||||
@ -49,6 +51,8 @@ struct PickTargetQueries<'w, 's> {
|
|||||||
parents: Query<'w, 's, &'static ChildOf>,
|
parents: Query<'w, 's, &'static ChildOf>,
|
||||||
editor_only: Query<'w, 's, (), With<EditorOnly>>,
|
editor_only: Query<'w, 's, (), With<EditorOnly>>,
|
||||||
material_drop: Option<Res<'w, MaterialDropState>>,
|
material_drop: Option<Res<'w, MaterialDropState>>,
|
||||||
|
terrain_sculpt: Res<'w, TerrainSculptState>,
|
||||||
|
viewport_ui: Res<'w, ViewportUiState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Plugin for EditorSelectionPlugin {
|
impl Plugin for EditorSelectionPlugin {
|
||||||
@ -113,6 +117,21 @@ fn handle_pick_events(
|
|||||||
gizmo_targets: Query<&GizmoTarget>,
|
gizmo_targets: Query<&GizmoTarget>,
|
||||||
hierarchy: Option<Res<HierarchyPanelState>>,
|
hierarchy: Option<Res<HierarchyPanelState>>,
|
||||||
) -> Result {
|
) -> 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
|
if pick_targets
|
||||||
.material_drop
|
.material_drop
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@ -220,6 +239,10 @@ fn handle_pick_events(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[expect(
|
||||||
|
clippy::too_many_arguments,
|
||||||
|
reason = "selection cycling reads independent viewport input ownership resources"
|
||||||
|
)]
|
||||||
fn cycle_overlapping_viewport_pick(
|
fn cycle_overlapping_viewport_pick(
|
||||||
mut ui_state: ResMut<UiState>,
|
mut ui_state: ResMut<UiState>,
|
||||||
mut selected: ResMut<SelectedEntity>,
|
mut selected: ResMut<SelectedEntity>,
|
||||||
@ -228,7 +251,11 @@ fn cycle_overlapping_viewport_pick(
|
|||||||
buttons: Res<ButtonInput<MouseButton>>,
|
buttons: Res<ButtonInput<MouseButton>>,
|
||||||
display: Res<ViewportDisplayMode>,
|
display: Res<ViewportDisplayMode>,
|
||||||
material_drop: Option<Res<MaterialDropState>>,
|
material_drop: Option<Res<MaterialDropState>>,
|
||||||
|
terrain_sculpt: Res<TerrainSculptState>,
|
||||||
) -> Result {
|
) -> Result {
|
||||||
|
if terrain_sculpt.active {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
if material_drop
|
if material_drop
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.is_some_and(MaterialDropState::captures_viewport_input)
|
.is_some_and(MaterialDropState::captures_viewport_input)
|
||||||
@ -415,13 +442,14 @@ fn sync_gizmo_targets(
|
|||||||
hierarchy: Option<Res<HierarchyPanelState>>,
|
hierarchy: Option<Res<HierarchyPanelState>>,
|
||||||
display: Res<ViewportDisplayMode>,
|
display: Res<ViewportDisplayMode>,
|
||||||
brush_mode: Res<BrushEditMode>,
|
brush_mode: Res<BrushEditMode>,
|
||||||
|
terrain_sculpt: Res<TerrainSculptState>,
|
||||||
) {
|
) {
|
||||||
ui_state
|
ui_state
|
||||||
.selected_entities
|
.selected_entities
|
||||||
.retain(|entity| transforms.contains(entity) && !editor_only.contains(entity));
|
.retain(|entity| transforms.contains(entity) && !editor_only.contains(entity));
|
||||||
selected.0 = ui_state.selected_entities.as_slice().first().copied();
|
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 {
|
for (entity, brush_element_gizmo) in &targets {
|
||||||
if brush_element_gizmo.is_none() {
|
if brush_element_gizmo.is_none() {
|
||||||
commands.entity(entity).remove::<GizmoTarget>();
|
commands.entity(entity).remove::<GizmoTarget>();
|
||||||
@ -474,6 +502,7 @@ mod tests {
|
|||||||
.init_resource::<SelectedEntity>()
|
.init_resource::<SelectedEntity>()
|
||||||
.init_resource::<ViewportDisplayMode>()
|
.init_resource::<ViewportDisplayMode>()
|
||||||
.init_resource::<BrushEditMode>()
|
.init_resource::<BrushEditMode>()
|
||||||
|
.init_resource::<TerrainSculptState>()
|
||||||
.add_systems(Update, sync_gizmo_targets);
|
.add_systems(Update, sync_gizmo_targets);
|
||||||
|
|
||||||
app.update();
|
app.update();
|
||||||
@ -488,4 +517,26 @@ mod tests {
|
|||||||
assert!(app.world().get::<GizmoTarget>(first).is_some());
|
assert!(app.world().get::<GizmoTarget>(first).is_some());
|
||||||
assert!(app.world().get::<GizmoTarget>(second).is_none());
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
741
crates/editor/src/viewport/terrain_sculpt.rs
Normal file
741
crates/editor/src/viewport/terrain_sculpt.rs
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -60,7 +60,7 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
|
|||||||
| Document | Purpose |
|
| Document | Purpose |
|
||||||
|----------|---------|
|
|----------|---------|
|
||||||
| [editor/release-notes.md](editor/release-notes.md) | Editor framework 1.0 baseline |
|
| [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/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/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 |
|
| [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/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/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/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/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 |
|
| [editor/evaluations/production-readiness/](editor/evaluations/production-readiness/) | Current versioned production gate, evidence matrix, candidate commands, soak protocol, and independent sign-off requirements |
|
||||||
|
|
||||||
|
|||||||
@ -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 |
|
| [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 |
|
| [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 |
|
| [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-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/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/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/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-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/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 |
|
| [evaluations/production-readiness/](evaluations/production-readiness/) | Current #50 release-candidate matrix and required clean-checkout, soak, performance, limitation, and independent-signoff evidence |
|
||||||
|
|
||||||
|
|||||||
15
docs/editor/evaluations/README.md
Normal file
15
docs/editor/evaluations/README.md
Normal 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.
|
||||||
31
docs/editor/evaluations/terrain-sculpt-tools/README.md
Normal file
31
docs/editor/evaluations/terrain-sculpt-tools/README.md
Normal 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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
BIN
docs/editor/evaluations/terrain-sculpt-tools/terrain-sculpt-raise-stroke.png
(Stored with Git LFS)
Normal file
BIN
docs/editor/evaluations/terrain-sculpt-tools/terrain-sculpt-raise-stroke.png
(Stored with Git LFS)
Normal file
Binary file not shown.
@ -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
|
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.
|
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
|
`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,
|
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
|
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`;
|
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.
|
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
|
Material layers and weight painting are tracked by Gitea `#24`. The persistence decision is recorded in
|
||||||
painting are tracked by `#24`. The persistence decision is recorded in
|
|
||||||
[ADR 0039](../adr/0039-inline-height-grid-terrain-foundation.md).
|
[ADR 0039](../adr/0039-inline-height-grid-terrain-foundation.md).
|
||||||
|
|
||||||
Live native-Wayland evidence and focused verification are recorded in the
|
Live native-Wayland evidence and focused verification are recorded in the
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user