Add transactional physics placement
Some checks are pending
CI / Format, lint, test, build (push) Waiting to run

This commit is contained in:
Rbanh 2026-07-12 22:07:17 -04:00
parent d1a56f77cc
commit 0e7aed40f1
21 changed files with 1253 additions and 19 deletions

View File

@ -0,0 +1,36 @@
# Physics Placement Tool - 2026-07-12
Issue: Gitea #25 (`BS-JD-304`)
## Outcome
Selected authored props can be released into deterministic Avian gravity, preview their
settled transforms in the Scene viewport, and either commit the result as one undoable
transform operation or cancel back to the exact pre-placement state.
## Contract
- Physics is paused during normal Edit mode and while Play mode is paused.
- Placement runs only in Edit mode and owns a transactional physics session.
- Selected mutable level objects must have active rigid-body and non-trigger collider
authoring. Invalid selections produce a precise, non-destructive diagnostic.
- Selected bodies are temporarily dynamic with clean motion state.
- Other movable bodies are temporarily static so they remain collision surfaces without
advancing unrelated authored state.
- Commit restores all temporary runtime overrides and records only changed local transforms
as one grouped history command.
- Cancel restores transforms and runtime physics state exactly and creates no history entry.
- Escape cancels. Enter commits once the session has reached rest. A bounded timeout keeps
unstable scenes controllable.
## Work
- [x] Establish Edit/Play ownership of `Time<Physics>` and preserve paused single-step PIE.
- [x] Implement placement validation, runtime snapshots, deterministic stepping, settling,
timeout, commit, and cancel.
- [x] Add the placement action and session controls to the existing horizontal viewport
toolbar plus a compact status HUD.
- [x] Add unit/integration coverage for prerequisites, settling, cancel, commit, and history.
- [x] Update editor workflow documentation and implementation checklist.
- [x] Run source gates and live Wayland acceptance; packaged tests remain skipped by request.
- [x] Commit, push, attach native Gitea evidence, and close #25.

View File

@ -173,6 +173,7 @@ deep-stale variants.
| 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 |
| Terrain card → Material Layers; selected Terrain → viewport paint tool | Assign up to four shared materials, then Paint/Erase normalized layer weights with live blended preview; release commits one undo step and Escape/right-click restores the pre-stroke map |
| Selected physics props → viewport hand/down-arrow tool | Release the selection into real gravity/colliders; check commits one grouped transform edit, while Escape/X restores the exact starting state |
| 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 |
@ -404,6 +405,7 @@ crates/
- [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] Four shared terrain Material/Material Instance layers with compact normalized sample weights, blended raster hydration, drag/browse assignment, Paint/Erase preview, exact cancel, and one undo transaction per stroke ([ADR 0040](docs/adr/0040-terrain-material-layer-weights.md), [terrain guide](docs/editor/terrain.md), [Gitea #24](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/24))
- [x] Transactional physics placement with real Avian gravity/colliders, paused Edit-mode physics, prerequisite diagnostics, isolated non-selected bodies, exact cancel, and grouped transform undo ([ADR 0041](docs/adr/0041-transactional-editor-physics-placement.md), [workflow guide](docs/editor/physics-placement.md), [Gitea #25](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/25))
- [x] Non-blocking native file/folder/confirmation broker across scene, asset, prefab, composition, collaboration, and Project Browser workflows ([ADR 0038](docs/adr/0038-non-blocking-native-dialog-broker.md), [workflow guide](docs/editor/native-dialogs.md), [Gitea #52](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/52))
- [x] Asset import, static mesh/prefab placement, texture assignment, and selection export
- [x] PIE player-only snapshot/restore (authored `LevelObject` edits persist on stop)

View File

@ -197,6 +197,24 @@
),
dependencies: [],
),
(
id: ("78c6850b-5a76-4a4f-b48e-7fb00f6dd1ed"),
path: "assets/levels/physics_placement_showcase.scn.ron",
label: "physics_placement_showcase.scn",
kind_tag: "Level",
import_settings: (
scale: 1.0,
generate_collider: true,
lod0_only: true,
placement_mode: StaticAsset,
hierarchy_mode: SingleActor,
material_policy: SourceMaterials,
static_mesh_manifest_path: None,
animation_manifest_path: None,
default_animation_clip_id: None,
),
dependencies: [],
),
(
id: ("706e604e-1a63-4e97-b35d-eeb1d88fe3a9"),
path: "assets/levels/navigation_authoring_showcase.scn.ron",

View File

@ -0,0 +1,164 @@
(schema_version: 4, resources: {}, entities: {
1: (components: {
"bevy_ecs::name::Name": "Placement Floor",
"bevy_transform::components::transform::Transform": (
translation: (0.0, -0.25, 0.0),
rotation: (0.0, 0.0, 0.0, 1.0),
scale: (1.0, 1.0, 1.0),
),
"shared::components::ActorId": ("physics-placement-floor"),
"shared::components::ActorKind": StaticMesh,
"shared::components::EditorVisibility": (visible: true),
"shared::components::HierarchySiblingIndex": (0),
"shared::components::LevelObject": (),
"shared::components::MaterialDesc": (
shader: (kind: StandardLit, schema_path: None, shader_path: None),
base_color: (r: 0.12, g: 0.14, b: 0.16, a: 1.0),
metallic: 0.05,
roughness: 0.72,
emissive_color: (r: 1.0, g: 1.0, b: 1.0, a: 1.0),
emissive_intensity: 0.0,
base_color_texture: None,
emissive_texture: None,
normal_map_texture: None,
metallic_roughness_texture: None,
material_asset_path: None,
parameters: [],
textures: [],
),
"shared::components::Primitive": (shape: Box, size: (14.0, 0.5, 10.0)),
"shared::components::RigidBodyDesc": (body: Static),
"shared::components::ColliderDesc": (
enabled: true,
is_trigger: false,
shape: Cuboid(x_length: 14.0, y_length: 0.5, z_length: 10.0),
),
}),
2: (components: {
"bevy_ecs::name::Name": "Placement Prop A",
"bevy_transform::components::transform::Transform": (
translation: (-2.0, 3.0, 0.0),
rotation: (0.1305262, 0.0, 0.0, 0.9914449),
scale: (1.0, 1.0, 1.0),
),
"shared::components::ActorId": ("physics-placement-prop-a"),
"shared::components::ActorKind": StaticMesh,
"shared::components::EditorVisibility": (visible: true),
"shared::components::HierarchySiblingIndex": (1),
"shared::components::LevelObject": (),
"shared::components::MaterialDesc": (
shader: (kind: StandardLit, schema_path: None, shader_path: None),
base_color: (r: 0.88, g: 0.48, b: 0.12, a: 1.0),
metallic: 0.1,
roughness: 0.42,
emissive_color: (r: 1.0, g: 1.0, b: 1.0, a: 1.0),
emissive_intensity: 0.0,
base_color_texture: None,
emissive_texture: None,
normal_map_texture: None,
metallic_roughness_texture: None,
material_asset_path: None,
parameters: [],
textures: [],
),
"shared::components::Primitive": (shape: Box, size: (1.2, 1.2, 1.2)),
"shared::components::RigidBodyDesc": (body: Static),
"shared::components::ColliderDesc": (
enabled: true,
is_trigger: false,
shape: Cuboid(x_length: 1.2, y_length: 1.2, z_length: 1.2),
),
}),
3: (components: {
"bevy_ecs::name::Name": "Placement Prop B",
"bevy_transform::components::transform::Transform": (
translation: (0.0, 5.0, 0.0),
rotation: (0.0, 0.2164396, 0.0, 0.976296),
scale: (1.0, 1.0, 1.0),
),
"shared::components::ActorId": ("physics-placement-prop-b"),
"shared::components::ActorKind": StaticMesh,
"shared::components::EditorVisibility": (visible: true),
"shared::components::HierarchySiblingIndex": (2),
"shared::components::LevelObject": (),
"shared::components::MaterialDesc": (
shader: (kind: StandardLit, schema_path: None, shader_path: None),
base_color: (r: 0.10, g: 0.58, b: 0.72, a: 1.0),
metallic: 0.15,
roughness: 0.35,
emissive_color: (r: 1.0, g: 1.0, b: 1.0, a: 1.0),
emissive_intensity: 0.0,
base_color_texture: None,
emissive_texture: None,
normal_map_texture: None,
metallic_roughness_texture: None,
material_asset_path: None,
parameters: [],
textures: [],
),
"shared::components::Primitive": (shape: Box, size: (1.0, 1.6, 1.0)),
"shared::components::RigidBodyDesc": (body: Static),
"shared::components::ColliderDesc": (
enabled: true,
is_trigger: false,
shape: Cuboid(x_length: 1.0, y_length: 1.6, z_length: 1.0),
),
}),
4: (components: {
"bevy_ecs::name::Name": "Placement Prop C",
"bevy_transform::components::transform::Transform": (
translation: (2.0, 7.0, 0.0),
rotation: (0.092296, 0.092296, -0.008077, 0.991405),
scale: (1.0, 1.0, 1.0),
),
"shared::components::ActorId": ("physics-placement-prop-c"),
"shared::components::ActorKind": StaticMesh,
"shared::components::EditorVisibility": (visible: true),
"shared::components::HierarchySiblingIndex": (3),
"shared::components::LevelObject": (),
"shared::components::MaterialDesc": (
shader: (kind: StandardLit, schema_path: None, shader_path: None),
base_color: (r: 0.62, g: 0.24, b: 0.56, a: 1.0),
metallic: 0.08,
roughness: 0.5,
emissive_color: (r: 1.0, g: 1.0, b: 1.0, a: 1.0),
emissive_intensity: 0.0,
base_color_texture: None,
emissive_texture: None,
normal_map_texture: None,
metallic_roughness_texture: None,
material_asset_path: None,
parameters: [],
textures: [],
),
"shared::components::Primitive": (shape: Box, size: (1.4, 0.8, 1.0)),
"shared::components::RigidBodyDesc": (body: Static),
"shared::components::ColliderDesc": (
enabled: true,
is_trigger: false,
shape: Cuboid(x_length: 1.4, y_length: 0.8, z_length: 1.0),
),
}),
5: (components: {
"bevy_ecs::name::Name": "Placement Sun",
"bevy_transform::components::transform::Transform": (
translation: (0.0, 8.0, 2.0),
rotation: (-0.3826834, 0.0, 0.0, 0.9238795),
scale: (1.0, 1.0, 1.0),
),
"shared::components::ActorId": ("physics-placement-sun"),
"shared::components::ActorKind": Light,
"shared::components::EditorVisibility": (visible: true),
"shared::components::HierarchySiblingIndex": (4),
"shared::components::LevelObject": (),
"shared::components::LightDesc": (
kind: Directional,
color: (r: 1.0, g: 0.95, b: 0.85, a: 1.0),
intensity: 100000.0,
range: 0.0,
shadows: true,
inner_angle_deg: 25.0,
outer_angle_deg: 35.0,
),
}),
})

View File

@ -311,11 +311,22 @@ fn sync_terrain_layer_material_bindings(
);
handle
});
queue_terrain_material_binding(&mut commands, entity, handle);
}
}
fn queue_terrain_material_binding(
commands: &mut Commands,
entity: Entity,
handle: Handle<TerrainLayerMaterial>,
) {
// Generated render entities can be removed by a scene switch after this system's query
// but before deferred commands apply. That lifecycle race is expected and must not abort
// the editor while opening another scene.
commands
.entity(entity)
.remove::<MeshMaterial3d<StandardMaterial>>()
.insert(MeshMaterial3d(handle));
}
.try_remove::<MeshMaterial3d<StandardMaterial>>()
.try_insert(MeshMaterial3d(handle));
}
fn terrain_layer_revision(layers: &[TerrainMaterialLayer]) -> u64 {
@ -469,8 +480,8 @@ fn sync_surface_material_bindings(
{
commands
.entity(entity)
.remove::<MeshMaterial3d<SurfaceMaterial>>()
.insert(MeshMaterial3d(handle));
.try_remove::<MeshMaterial3d<SurfaceMaterial>>()
.try_insert(MeshMaterial3d(handle));
}
}
continue;
@ -540,8 +551,8 @@ fn sync_surface_material_bindings(
{
commands
.entity(entity)
.remove::<MeshMaterial3d<SurfaceMaterial>>()
.insert(MeshMaterial3d(handle));
.try_remove::<MeshMaterial3d<SurfaceMaterial>>()
.try_insert(MeshMaterial3d(handle));
}
continue;
}
@ -565,8 +576,8 @@ fn sync_surface_material_bindings(
if surface_handle.is_none_or(|current| current.0 != handle) {
commands
.entity(entity)
.remove::<MeshMaterial3d<StandardMaterial>>()
.insert(MeshMaterial3d(handle));
.try_remove::<MeshMaterial3d<StandardMaterial>>()
.try_insert(MeshMaterial3d(handle));
}
}
}
@ -957,6 +968,21 @@ pub fn compose_surface_shader(evaluator: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
use bevy::ecs::world::CommandQueue;
#[test]
fn stale_generated_terrain_binding_is_nonfatal_during_scene_switch() {
let mut world = World::new();
let entity = world.spawn_empty().id();
let mut queue = CommandQueue::default();
let mut commands = Commands::new(&mut queue, &world);
queue_terrain_material_binding(&mut commands, entity, Handle::default());
world.entity_mut(entity).despawn();
queue.apply(&mut world);
assert!(world.get_entity(entity).is_err());
}
#[test]
fn rejects_resource_bindings_and_missing_entry() {

View File

@ -39,6 +39,7 @@ pub use viewport::brush_tool;
pub use viewport::camera;
pub use viewport::gizmos;
pub use viewport::material_drop;
pub use viewport::physics_placement;
pub use viewport::render_view;
pub use viewport::rendering_diagnostics;
pub use viewport::selection;
@ -68,6 +69,7 @@ use history::EditorHistoryPlugin;
use infra::EditorInfraPlugin;
use material_drop::MaterialDropPlugin;
use operators::OperatorPlugin;
use physics_placement::PhysicsPlacementPlugin;
use play::audio_preview::AudioPreviewPlugin;
use play::PlaySessionPlugin;
use project::collaboration::CollaborationPlugin;
@ -119,6 +121,7 @@ impl PluginGroup for EditorPluginGroup {
.add(AudioPreviewPlugin)
.add(ActorIconsPlugin)
.add(MaterialDropPlugin)
.add(PhysicsPlacementPlugin)
.add(EditorSelectionPlugin)
.add(SelectionOutlinePlugin)
.add(EditorGizmoPlugin)

View File

@ -1,9 +1,12 @@
use std::time::Duration;
use avian3d::prelude::{Physics, PhysicsTime};
use bevy::prelude::*;
use bevy::window::{CursorGrabMode, CursorOptions, PrimaryWindow};
use bevy_egui::PrimaryEguiContext;
use game::player::PlayerCamera;
use game::{GameInputEnabled, GameInputFocused, SimEnabled};
use protocol::PlayerInputIntent;
use protocol::{PlayerInputIntent, SIM_TICK_RATE_HZ};
use sim::{Crouching, Grounded, JumpState, Player, PlayerVelocity};
use crate::camera::EditorCamera;
@ -195,6 +198,7 @@ pub(crate) fn sync_mode_resources_and_cameras(
mut step: ResMut<PlayStepOnce>,
mut input_enabled: ResMut<GameInputEnabled>,
mut sim_enabled: ResMut<SimEnabled>,
mut physics_time: ResMut<Time<Physics>>,
mut editor_cameras: Query<&mut Camera, With<EditorCamera>>,
mut player_cameras: Query<&mut Camera, (With<PlayerCamera>, Without<EditorCamera>)>,
mut egui_cameras: Query<
@ -212,9 +216,11 @@ pub(crate) fn sync_mode_resources_and_cameras(
let ejected = playing && *possession == PlayPossession::Ejected;
let possessed = playing && *possession == PlayPossession::Possessed;
sim_enabled.0 = playing && (!paused.0 || step.0);
let stepping = step.0;
sim_enabled.0 = playing && (!paused.0 || stepping);
input_enabled.0 = possessed && !paused.0;
if step.0 {
sync_editor_physics_clock(&mut physics_time, playing, paused.0, stepping);
if stepping {
step.0 = false;
}
@ -240,6 +246,22 @@ pub(crate) fn sync_mode_resources_and_cameras(
}
}
fn sync_editor_physics_clock(
physics_time: &mut Time<Physics>,
playing: bool,
paused: bool,
stepping: bool,
) {
if playing && !paused {
physics_time.unpause();
return;
}
physics_time.pause();
if playing && stepping {
physics_time.advance_by(Duration::from_secs_f64(1.0 / SIM_TICK_RATE_HZ as f64));
}
}
fn sync_play_input_focus(
mode: Res<State<EditorMode>>,
possession: Res<PlayPossession>,
@ -414,3 +436,29 @@ pub fn toggle_play_possession_world(world: &mut World) {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn editor_physics_clock_tracks_edit_play_pause_and_single_step() {
let mut time = Time::<Physics>::default();
sync_editor_physics_clock(&mut time, false, false, false);
assert!(time.is_paused());
sync_editor_physics_clock(&mut time, true, false, false);
assert!(!time.is_paused());
sync_editor_physics_clock(&mut time, true, true, false);
assert!(time.is_paused());
sync_editor_physics_clock(&mut time, true, true, true);
assert!(time.is_paused());
assert_eq!(
time.delta(),
Duration::from_secs_f64(1.0 / SIM_TICK_RATE_HZ as f64)
);
}
}

View File

@ -20,6 +20,10 @@ 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::physics_placement::{
begin_physics_placement, request_physics_placement, PhysicsPlacementPhase,
PhysicsPlacementRequest, PhysicsPlacementState,
};
use crate::viewport::terrain_paint::{TerrainPaintMode, TerrainPaintState};
use crate::viewport::terrain_sculpt::{TerrainSculptMode, TerrainSculptState};
use crate::viewport::{
@ -146,11 +150,12 @@ pub fn viewport_tab_ui(
return;
}
scene_view_overlay_toolbar(world, ui.ctx(), rect);
scene_view_overlay_toolbar(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_terrain_sculpt_hints(world, ui.ctx(), rect);
scene_view_terrain_paint_hints(world, ui.ctx(), rect);
scene_view_physics_placement_hud(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);
@ -582,7 +587,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,
selected_entities: &SelectedEntities,
) {
let mut options_open = world.resource::<ViewportUiState>().options_open;
let mut shortcuts_open = world.resource::<ViewportUiState>().shortcuts_open;
let terrain_selected = world
@ -737,6 +747,56 @@ fn scene_view_overlay_toolbar(world: &mut World, ctx: &egui::Context, scene_rect
}
}
let placement_active = world.resource::<PhysicsPlacementState>().active();
ui.separator();
if placement_active {
let phase = world.resource::<PhysicsPlacementState>().phase;
ui.label(
egui::RichText::new(phase.label())
.color(match phase {
PhysicsPlacementPhase::Settled => SUCCESS,
PhysicsPlacementPhase::TimedOut => WARNING,
_ => SELECTION,
})
.strong()
.small(),
);
if icon_button_small(
ui,
icons::CHECK,
"Commit physics placement (Enter when settled)",
)
.clicked()
{
request_physics_placement(world, PhysicsPlacementRequest::Commit);
}
if icon_button_small(ui, icons::X, "Cancel physics placement (Esc)")
.clicked()
{
request_physics_placement(world, PhysicsPlacementRequest::Cancel);
}
} else {
let selected = selected_entities.iter().collect::<Vec<_>>();
let can_start = !selected.is_empty();
let clicked = ui
.add_enabled(
can_start,
egui::Button::new(phosphor_icon(icons::HAND_ARROW_DOWN, 16.0)),
)
.on_hover_text(if can_start {
"Release selected props into physics"
} else {
"Select one or more props to place with physics"
})
.clicked();
if clicked && begin_physics_placement(world, selected).is_ok() {
world.resource_mut::<TerrainSculptState>().stop();
world.resource_mut::<TerrainPaintState>().stop();
world.resource_mut::<BrushToolState>().cancel();
world.resource_mut::<ViewportClick>().0 = None;
}
}
let sculpt_active = world.resource::<TerrainSculptState>().active;
let paint_active = world.resource::<TerrainPaintState>().active;
if terrain_selected.is_some() || sculpt_active || paint_active {
@ -950,6 +1010,66 @@ fn scene_view_overlay_toolbar(world: &mut World, ctx: &egui::Context, scene_rect
world.resource_mut::<ViewportUiState>().shortcuts_open = shortcuts_open;
}
fn scene_view_physics_placement_hud(world: &World, ctx: &egui::Context, scene_rect: egui::Rect) {
let state = world.resource::<PhysicsPlacementState>();
if !state.active() {
let Some(error) = state.last_error.as_deref() else {
return;
};
egui::Area::new(egui::Id::new("scene_view_physics_placement_error"))
.fixed_pos(scene_rect.left_top() + egui::vec2(8.0, 52.0))
.interactable(false)
.show(ctx, |ui| {
overlay_chip_frame().show(ui, |ui| {
ui.label(
egui::RichText::new("Physics placement unavailable")
.color(ERROR)
.strong(),
);
for line in error.lines().take(4) {
ui.label(egui::RichText::new(line).color(TEXT_DIM).small());
}
});
});
return;
}
egui::Area::new(egui::Id::new("scene_view_physics_placement_hud"))
.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(phosphor_icon(icons::HAND_ARROW_DOWN, 16.0).color(SELECTION));
ui.label(
egui::RichText::new(format!("Physics Placement / {}", state.phase.label()))
.color(match state.phase {
PhysicsPlacementPhase::Settled => SUCCESS,
PhysicsPlacementPhase::TimedOut => WARNING,
_ => SELECTION,
})
.strong(),
);
ui.label(
egui::RichText::new(format!(
"{} props / {:.1}s",
state.selected_count,
state.simulated_seconds()
))
.color(TEXT_DIM)
.monospace()
.small(),
);
});
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| {
key_hint(ui, "Esc", "Cancel");
key_hint(ui, "Enter", "Commit when settled");
});
});
});
}
fn scene_options_popover(
world: &mut World,
ctx: &egui::Context,

View File

@ -8,6 +8,7 @@ pub mod camera;
pub mod gizmos;
pub mod material_drop;
mod panel;
pub mod physics_placement;
pub mod render_view;
pub mod rendering_diagnostics;
pub mod selection;
@ -26,6 +27,7 @@ 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 physics_placement::{PhysicsPlacementPlugin, PhysicsPlacementState};
pub use terrain_paint::{TerrainPaintMode, TerrainPaintPlugin, TerrainPaintState};
pub use terrain_sculpt::{TerrainSculptMode, TerrainSculptPlugin, TerrainSculptState};
pub use viewport_mode::EditorViewportMode;

View File

@ -0,0 +1,670 @@
//! Transactional gravity placement using the editor's hydrated Avian world.
use std::collections::HashSet;
use std::time::Duration;
use avian3d::prelude::*;
use bevy::prelude::*;
use shared::{
authoring_component_active, AuthoringComponentStates, ColliderDesc, InspectorOrder,
PhysicsBody, RigidBodyDesc, COMPONENT_COLLIDER_DESC, COMPONENT_PHYSICS_BODY,
COMPONENT_RIGID_BODY_DESC,
};
use crate::history::set_transform_group_with_history;
use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus};
use crate::state::EditorMode;
use crate::ui::selection_ops::{entity_name, is_mutable_level_object};
const STEP_HZ: f64 = 120.0;
const STEPS_PER_FRAME: usize = 4;
const SETTLE_LINEAR_SPEED: f32 = 0.04;
const SETTLE_ANGULAR_SPEED: f32 = 0.04;
const SETTLE_STEPS: u32 = 30;
const MAX_STEPS: u32 = 1_200;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PhysicsPlacementPhase {
#[default]
Inactive,
Dropping,
Settled,
TimedOut,
}
impl PhysicsPlacementPhase {
pub fn label(self) -> &'static str {
match self {
Self::Inactive => "Inactive",
Self::Dropping => "Simulating",
Self::Settled => "Settled",
Self::TimedOut => "Preview stopped",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PhysicsPlacementRequest {
#[default]
None,
Commit,
Cancel,
}
#[derive(Component, Debug, Clone, Copy, Default)]
pub struct PhysicsPlacementPreview;
#[derive(Resource, Debug, Default)]
pub struct PhysicsPlacementState {
pub phase: PhysicsPlacementPhase,
pub request: PhysicsPlacementRequest,
pub last_error: Option<String>,
pub selected_count: usize,
pub simulated_steps: u32,
session: Option<PlacementSession>,
}
impl PhysicsPlacementState {
pub fn active(&self) -> bool {
self.session.is_some()
}
pub fn simulated_seconds(&self) -> f32 {
self.simulated_steps as f32 / STEP_HZ as f32
}
}
#[derive(Debug)]
struct PlacementSession {
selected: Vec<BodySnapshot>,
frozen: Vec<BodySnapshot>,
stable_steps: u32,
}
#[derive(Debug, Clone)]
struct BodySnapshot {
entity: Entity,
transform: Transform,
position: Option<Position>,
rotation: Option<Rotation>,
rigid_body: RigidBody,
linear_velocity: Option<LinearVelocity>,
angular_velocity: Option<AngularVelocity>,
gravity_scale: Option<GravityScale>,
sleeping: bool,
sleeping_disabled: bool,
rigid_body_disabled: bool,
}
impl BodySnapshot {
fn capture(world: &World, entity: Entity) -> Result<Self, String> {
Ok(Self {
entity,
transform: world
.get::<Transform>(entity)
.copied()
.ok_or_else(|| format!("{} has no Transform", entity_name(world, entity)))?,
position: world.get::<Position>(entity).copied(),
rotation: world.get::<Rotation>(entity).copied(),
rigid_body: world.get::<RigidBody>(entity).copied().ok_or_else(|| {
format!("{} has no hydrated rigid body", entity_name(world, entity))
})?,
linear_velocity: world.get::<LinearVelocity>(entity).copied(),
angular_velocity: world.get::<AngularVelocity>(entity).copied(),
gravity_scale: world.get::<GravityScale>(entity).copied(),
sleeping: world.get::<Sleeping>(entity).is_some(),
sleeping_disabled: world.get::<SleepingDisabled>(entity).is_some(),
rigid_body_disabled: world.get::<RigidBodyDisabled>(entity).is_some(),
})
}
}
pub struct PhysicsPlacementPlugin;
impl Plugin for PhysicsPlacementPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<PhysicsPlacementState>()
.add_systems(PostStartup, pause_physics_for_editor)
.add_systems(PostUpdate, tick_physics_placement)
.add_systems(OnExit(EditorMode::Editing), cancel_physics_placement);
}
}
fn pause_physics_for_editor(mut physics_time: ResMut<Time<Physics>>) {
physics_time.pause();
}
pub fn begin_physics_placement(
world: &mut World,
selected: impl IntoIterator<Item = Entity>,
) -> Result<(), String> {
if *world.resource::<State<EditorMode>>().get() != EditorMode::Editing {
return placement_error(world, "Physics placement is available only in Edit mode");
}
if world.resource::<PhysicsPlacementState>().active() {
return placement_error(world, "A physics placement preview is already active");
}
let mut unique = HashSet::new();
let selected = selected
.into_iter()
.filter(|entity| unique.insert(*entity))
.collect::<Vec<_>>();
validate_selection(world, &selected)?;
let selected_set = selected.iter().copied().collect::<HashSet<_>>();
let selected_snapshots = selected
.iter()
.map(|entity| BodySnapshot::capture(world, *entity))
.collect::<Result<Vec<_>, _>>()?;
let movable = world
.query::<(Entity, &RigidBody)>()
.iter(world)
.filter_map(|(entity, body)| {
(!selected_set.contains(&entity) && *body != RigidBody::Static).then_some(entity)
})
.collect::<Vec<_>>();
let frozen_snapshots = movable
.iter()
.map(|entity| BodySnapshot::capture(world, *entity))
.collect::<Result<Vec<_>, _>>()?;
for snapshot in &frozen_snapshots {
if let Ok(mut entity) = world.get_entity_mut(snapshot.entity) {
entity.insert(RigidBody::Static);
}
}
for snapshot in &selected_snapshots {
let global = world
.get::<GlobalTransform>(snapshot.entity)
.copied()
.unwrap_or_else(|| GlobalTransform::from(snapshot.transform));
if let Ok(mut entity) = world.get_entity_mut(snapshot.entity) {
entity.insert((
RigidBody::Dynamic,
LinearVelocity::ZERO,
AngularVelocity::ZERO,
GravityScale(1.0),
Position(global.translation()),
Rotation(global.rotation()),
PhysicsPlacementPreview,
));
entity.remove::<Sleeping>();
entity.remove::<RigidBodyDisabled>();
}
}
let selected_count = selected_snapshots.len();
let mut state = world.resource_mut::<PhysicsPlacementState>();
state.phase = PhysicsPlacementPhase::Dropping;
state.request = PhysicsPlacementRequest::None;
state.last_error = None;
state.selected_count = selected_count;
state.simulated_steps = 0;
state.session = Some(PlacementSession {
selected: selected_snapshots,
frozen: frozen_snapshots,
stable_steps: 0,
});
set_operator_status(
world,
OperatorPhase::Preview,
format!("Dropping {selected_count} selected prop(s)"),
Vec::new(),
);
Ok(())
}
pub fn request_physics_placement(world: &mut World, request: PhysicsPlacementRequest) {
if world.resource::<PhysicsPlacementState>().active() {
world.resource_mut::<PhysicsPlacementState>().request = request;
}
}
pub fn cancel_physics_placement(world: &mut World) {
finish_physics_placement(world, false);
}
pub fn commit_physics_placement(world: &mut World) {
finish_physics_placement(world, true);
}
fn finish_physics_placement(world: &mut World, commit: bool) {
let Some(mut state) = world.remove_resource::<PhysicsPlacementState>() else {
return;
};
let Some(session) = state.session.take() else {
world.insert_resource(state);
return;
};
let final_transforms = session
.selected
.iter()
.filter_map(|snapshot| {
world
.get::<Transform>(snapshot.entity)
.copied()
.map(|transform| (snapshot.entity, transform))
})
.collect::<Vec<_>>();
for snapshot in &session.frozen {
restore_snapshot(world, snapshot, true);
}
for snapshot in &session.selected {
restore_snapshot(world, snapshot, !commit);
if let Ok(mut entity) = world.get_entity_mut(snapshot.entity) {
entity.remove::<PhysicsPlacementPreview>();
}
}
if commit {
let changes = session.selected.iter().filter_map(|snapshot| {
final_transforms
.iter()
.find(|(entity, _)| *entity == snapshot.entity)
.map(|(_, new)| (snapshot.entity, snapshot.transform, *new))
});
set_transform_group_with_history(world, changes);
}
state.phase = PhysicsPlacementPhase::Inactive;
state.request = PhysicsPlacementRequest::None;
state.selected_count = 0;
state.simulated_steps = 0;
state.last_error = None;
world.insert_resource(state);
clear_operator_status(world);
}
fn tick_physics_placement(world: &mut World) {
let escape = world
.get_resource::<ButtonInput<KeyCode>>()
.is_some_and(|keys| keys.just_pressed(KeyCode::Escape));
let enter = world
.get_resource::<ButtonInput<KeyCode>>()
.is_some_and(|keys| keys.just_pressed(KeyCode::Enter));
let request = world.resource::<PhysicsPlacementState>().request;
let settled = matches!(
world.resource::<PhysicsPlacementState>().phase,
PhysicsPlacementPhase::Settled | PhysicsPlacementPhase::TimedOut
);
if escape || request == PhysicsPlacementRequest::Cancel {
cancel_physics_placement(world);
return;
}
if request == PhysicsPlacementRequest::Commit || (enter && settled) {
commit_physics_placement(world);
return;
}
if world.resource::<PhysicsPlacementState>().phase != PhysicsPlacementPhase::Dropping {
return;
}
for _ in 0..STEPS_PER_FRAME {
world
.resource_mut::<Time<Physics>>()
.advance_by(Duration::from_secs_f64(1.0 / STEP_HZ));
world.run_schedule(FixedPostUpdate);
}
let Some(mut state) = world.remove_resource::<PhysicsPlacementState>() else {
return;
};
state.simulated_steps = state.simulated_steps.saturating_add(STEPS_PER_FRAME as u32);
let Some(session) = state.session.as_mut() else {
world.insert_resource(state);
return;
};
let at_rest = session.selected.iter().all(|snapshot| {
let linear = world
.get::<LinearVelocity>(snapshot.entity)
.map_or(0.0, |velocity| velocity.length());
let angular = world
.get::<AngularVelocity>(snapshot.entity)
.map_or(0.0, |velocity| velocity.length());
(linear <= SETTLE_LINEAR_SPEED && angular <= SETTLE_ANGULAR_SPEED)
|| world.get::<Sleeping>(snapshot.entity).is_some()
});
session.stable_steps = if at_rest {
session.stable_steps.saturating_add(STEPS_PER_FRAME as u32)
} else {
0
};
let mut status = None;
if session.stable_steps >= SETTLE_STEPS {
state.phase = PhysicsPlacementPhase::Settled;
status = Some((
OperatorPhase::Preview,
"Placement settled; commit or cancel".to_string(),
Vec::new(),
));
} else if state.simulated_steps >= MAX_STEPS {
state.phase = PhysicsPlacementPhase::TimedOut;
status = Some((
OperatorPhase::Preview,
"Placement reached its 10 second preview limit".to_string(),
vec!["Bodies are still moving; commit the current pose or cancel".to_string()],
));
}
world.insert_resource(state);
if let Some((phase, hint, warnings)) = status {
set_operator_status(world, phase, hint, warnings);
}
}
fn validate_selection(world: &mut World, selected: &[Entity]) -> Result<(), String> {
if selected.is_empty() {
return placement_error(world, "Select at least one prop to place with physics");
}
let mut problems = Vec::new();
for entity in selected {
let name = entity_name(world, *entity);
if !is_mutable_level_object(world, *entity) {
problems.push(format!(
"{name}: selection is locked, linked, or not a level object"
));
continue;
}
let states = world.get::<AuthoringComponentStates>(*entity);
let order = world.get::<InspectorOrder>(*entity);
let split_body = world.get::<RigidBodyDesc>(*entity).is_some()
&& authoring_component_active(states, order, COMPONENT_RIGID_BODY_DESC);
let split_collider = world.get::<ColliderDesc>(*entity).filter(|collider| {
collider.enabled
&& !collider.is_trigger
&& authoring_component_active(states, order, COMPONENT_COLLIDER_DESC)
});
let legacy = world.get::<PhysicsBody>(*entity).is_some()
&& authoring_component_active(states, order, COMPONENT_PHYSICS_BODY);
if !split_body && !legacy {
problems.push(format!("{name}: add and enable a Rigid Body component"));
}
if split_collider.is_none() && !legacy {
problems.push(format!(
"{name}: add and enable a non-trigger Collider component"
));
}
if world.get::<RigidBody>(*entity).is_none() {
problems.push(format!("{name}: rigid body has not hydrated yet"));
}
if !has_runtime_collider(world, *entity) {
problems.push(format!(
"{name}: collider has not hydrated or finished cooking"
));
}
}
if problems.is_empty() {
Ok(())
} else {
placement_error(world, problems.join("\n"))
}
}
fn has_runtime_collider(world: &World, root: Entity) -> bool {
let mut pending = vec![root];
while let Some(entity) = pending.pop() {
if world.get::<Collider>(entity).is_some() {
return true;
}
if let Some(children) = world.get::<Children>(entity) {
pending.extend(children.iter());
}
}
false
}
fn restore_snapshot(world: &mut World, snapshot: &BodySnapshot, restore_pose: bool) {
let Ok(mut entity) = world.get_entity_mut(snapshot.entity) else {
return;
};
entity.insert(snapshot.rigid_body);
restore_optional(&mut entity, snapshot.linear_velocity);
restore_optional(&mut entity, snapshot.angular_velocity);
restore_optional(&mut entity, snapshot.gravity_scale);
restore_marker::<Sleeping>(&mut entity, snapshot.sleeping);
restore_marker::<SleepingDisabled>(&mut entity, snapshot.sleeping_disabled);
restore_marker::<RigidBodyDisabled>(&mut entity, snapshot.rigid_body_disabled);
if restore_pose {
entity.insert(snapshot.transform);
restore_optional(&mut entity, snapshot.position);
restore_optional(&mut entity, snapshot.rotation);
}
}
fn restore_optional<T: Component + Clone>(entity: &mut EntityWorldMut<'_>, value: Option<T>) {
if let Some(value) = value {
entity.insert(value);
} else {
entity.remove::<T>();
}
}
fn restore_marker<T: Component + Default>(entity: &mut EntityWorldMut<'_>, present: bool) {
if present {
entity.insert(T::default());
} else {
entity.remove::<T>();
}
}
fn placement_error<T>(world: &mut World, message: impl Into<String>) -> Result<T, String> {
let message = message.into();
world.resource_mut::<PhysicsPlacementState>().last_error = Some(message.clone());
set_operator_status(
world,
OperatorPhase::Blocked,
"Physics placement could not start".to_string(),
message.lines().map(str::to_string).collect(),
);
Err(message)
}
fn set_operator_status(
world: &mut World,
phase: OperatorPhase,
hint: String,
warnings: Vec<String>,
) {
if let Some(mut operator) = world.get_resource_mut::<ActiveOperator>() {
operator.status = Some(OperatorStatus {
id: "physics.placement".to_string(),
label: "Physics Placement".to_string(),
phase,
hint,
warnings,
});
}
}
fn clear_operator_status(world: &mut World) {
if let Some(mut operator) = world.get_resource_mut::<ActiveOperator>() {
if operator
.status
.as_ref()
.is_some_and(|status| status.id == "physics.placement")
{
operator.status = None;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::history::{apply_command_undo, EditorHistory};
use bevy::asset::{AssetApp, AssetPlugin};
use bevy::state::app::StatesPlugin;
use shared::LevelObject;
fn app_with_physics() -> App {
let mut app = App::new();
app.add_plugins((
MinimalPlugins,
AssetPlugin::default(),
StatesPlugin,
TransformPlugin,
PhysicsPlugins::default(),
))
.init_asset::<Mesh>()
.init_state::<EditorMode>()
.init_resource::<PhysicsPlacementState>()
.init_resource::<EditorHistory>();
app.finish();
app.cleanup();
app.update();
app.world_mut().resource_mut::<Time<Physics>>().pause();
app
}
fn spawn_placeable(world: &mut World, name: &str, transform: Transform) -> Entity {
world
.spawn((
Name::new(name.to_string()),
LevelObject,
RigidBodyDesc::default(),
ColliderDesc::default(),
RigidBody::Static,
Collider::cuboid(1.0, 1.0, 1.0),
transform,
))
.id()
}
#[test]
fn reports_missing_authoring_prerequisites_without_starting() {
let mut app = app_with_physics();
let entity = app
.world_mut()
.spawn((Name::new("No Physics"), LevelObject, Transform::default()))
.id();
let error = begin_physics_placement(app.world_mut(), [entity]).unwrap_err();
assert!(error.contains("add and enable a Rigid Body"));
assert!(error.contains("add and enable a non-trigger Collider"));
assert!(!app.world().resource::<PhysicsPlacementState>().active());
}
#[test]
fn cancel_restores_selected_and_unrelated_motion_state_exactly() {
let mut app = app_with_physics();
let selected = spawn_placeable(
app.world_mut(),
"Selected",
Transform::from_xyz(1.0, 4.0, 2.0),
);
app.world_mut().entity_mut(selected).insert((
RigidBody::Kinematic,
LinearVelocity(Vec3::new(1.0, 2.0, 3.0)),
AngularVelocity(Vec3::new(0.1, 0.2, 0.3)),
GravityScale(0.25),
SleepingDisabled,
));
let unrelated = app
.world_mut()
.spawn((
RigidBody::Dynamic,
Collider::sphere(0.5),
LinearVelocity(Vec3::X * 3.0),
Transform::from_xyz(8.0, 2.0, 0.0),
))
.id();
app.update();
begin_physics_placement(app.world_mut(), [selected]).unwrap();
assert_eq!(
app.world().get::<RigidBody>(selected),
Some(&RigidBody::Dynamic)
);
assert_eq!(
app.world().get::<RigidBody>(unrelated),
Some(&RigidBody::Static)
);
for _ in 0..10 {
tick_physics_placement(app.world_mut());
}
assert!(
app.world()
.get::<Transform>(selected)
.unwrap()
.translation
.y
< 4.0
);
cancel_physics_placement(app.world_mut());
assert_eq!(
app.world().get::<Transform>(selected).unwrap().translation,
Vec3::new(1.0, 4.0, 2.0)
);
assert_eq!(
app.world().get::<RigidBody>(selected),
Some(&RigidBody::Kinematic)
);
assert_eq!(
app.world().get::<LinearVelocity>(selected),
Some(&LinearVelocity(Vec3::new(1.0, 2.0, 3.0)))
);
assert_eq!(
app.world().get::<GravityScale>(selected),
Some(&GravityScale(0.25))
);
assert!(app.world().get::<SleepingDisabled>(selected).is_some());
assert_eq!(
app.world().get::<RigidBody>(unrelated),
Some(&RigidBody::Dynamic)
);
assert_eq!(
app.world().get::<LinearVelocity>(unrelated),
Some(&LinearVelocity(Vec3::X * 3.0))
);
assert_eq!(app.world().resource::<EditorHistory>().undo_depth(), 0);
}
#[test]
fn real_physics_drop_commits_once_and_undo_restores_start_transform() {
let mut app = app_with_physics();
app.world_mut().spawn((
RigidBody::Static,
Collider::cuboid(20.0, 0.5, 20.0),
Transform::from_xyz(0.0, -0.25, 0.0),
));
let start = Transform::from_xyz(0.0, 4.0, 0.0);
let prop = spawn_placeable(app.world_mut(), "Drop Prop", start);
app.update();
begin_physics_placement(app.world_mut(), [prop]).unwrap();
for _ in 0..400 {
tick_physics_placement(app.world_mut());
if app.world().resource::<PhysicsPlacementState>().phase
== PhysicsPlacementPhase::Settled
{
break;
}
}
assert_eq!(
app.world().resource::<PhysicsPlacementState>().phase,
PhysicsPlacementPhase::Settled
);
let settled = *app.world().get::<Transform>(prop).unwrap();
assert!(settled.translation.y < 1.0, "{settled:?}");
commit_physics_placement(app.world_mut());
assert_eq!(app.world().resource::<EditorHistory>().undo_depth(), 1);
assert_eq!(
app.world().resource::<EditorHistory>().status,
"Undo: Move Selection"
);
assert_eq!(app.world().get::<RigidBody>(prop), Some(&RigidBody::Static));
assert_eq!(*app.world().get::<Transform>(prop).unwrap(), settled);
apply_command_undo(app.world_mut());
assert_eq!(*app.world().get::<Transform>(prop).unwrap(), start);
}
}

View File

@ -23,6 +23,7 @@ use crate::visualizers::EditorVisualizerProxy;
use bevy_egui::egui;
use crate::viewport::material_drop::{MaterialDropSet, MaterialDropState};
use crate::viewport::physics_placement::PhysicsPlacementState;
use crate::viewport::scene_view_ray;
use crate::viewport::terrain_paint::TerrainPaintState;
use crate::viewport::terrain_sculpt::TerrainSculptState;
@ -54,6 +55,7 @@ struct PickTargetQueries<'w, 's> {
material_drop: Option<Res<'w, MaterialDropState>>,
terrain_sculpt: Res<'w, TerrainSculptState>,
terrain_paint: Res<'w, TerrainPaintState>,
physics_placement: Option<Res<'w, PhysicsPlacementState>>,
viewport_ui: Res<'w, ViewportUiState>,
}
@ -129,7 +131,13 @@ fn handle_pick_events(
for _ in click_events.read() {}
return Ok(());
}
if pick_targets.terrain_sculpt.active || pick_targets.terrain_paint.active {
if pick_targets.terrain_sculpt.active
|| pick_targets.terrain_paint.active
|| pick_targets
.physics_placement
.as_deref()
.is_some_and(PhysicsPlacementState::active)
{
viewport_click.0 = None;
for _ in click_events.read() {}
return Ok(());
@ -447,6 +455,7 @@ fn sync_gizmo_targets(
brush_mode: Res<BrushEditMode>,
terrain_sculpt: Res<TerrainSculptState>,
terrain_paint: Res<TerrainPaintState>,
physics_placement: Option<Res<PhysicsPlacementState>>,
) {
ui_state
.selected_entities
@ -457,6 +466,9 @@ fn sync_gizmo_targets(
|| brush_mode.is_element_mode()
|| terrain_sculpt.active
|| terrain_paint.active
|| physics_placement
.as_deref()
.is_some_and(PhysicsPlacementState::active)
{
for (entity, brush_element_gizmo) in &targets {
if brush_element_gizmo.is_none() {

View File

@ -55,6 +55,7 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
| [0038](adr/0038-non-blocking-native-dialog-broker.md) | Worker-owned native waits with one-shot main-thread workflow completion |
| [0039](adr/0039-inline-height-grid-terrain-foundation.md) | Inline authored height grids with deterministic runtime-only chunk hydration |
| [0040](adr/0040-terrain-material-layer-weights.md) | Four-channel terrain material layers, compact normalized weights, and raster transport |
| [0041](adr/0041-transactional-editor-physics-placement.md) | Paused editor physics ownership and transactional gravity placement |
## Editor framework
@ -82,9 +83,11 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
| [editor/collaborative-file-safety.md](editor/collaborative-file-safety.md) | Guarded authored writes, compact Git/read-only status, conflict recovery, and ownership providers |
| [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/physics-placement.md](editor/physics-placement.md) | Transactional gravity placement, diagnostics, isolation, and undo workflow |
| [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/terrain-material-layers/](editor/evaluations/terrain-material-layers/) | Live screenshot and acceptance results for terrain material assignment, blending, painting, and history |
| [editor/evaluations/physics-placement/](editor/evaluations/physics-placement/) | Live screenshot and acceptance results for physics settling, cancel, commit, undo, and redo |
| [editor/evaluations/navigation-authoring/](editor/evaluations/navigation-authoring/) | Live screenshots and acceptance results for navigation authoring and runtime parity |
| [editor/evaluations/production-readiness/](editor/evaluations/production-readiness/) | Current versioned production gate, evidence matrix, candidate commands, soak protocol, and independent sign-off requirements |

View File

@ -0,0 +1,44 @@
# ADR 0041: Transactional Editor Physics Placement
## Status
Accepted
## Context
The editor and game share one Bevy world and one Avian physics instance. Authored rigid
bodies are hydrated in Edit mode so collider visualization and queries remain available,
but advancing the shared simulation while editing mutates scene transforms outside history.
A physics-placement tool must use the real runtime colliders while preventing unrelated
dynamic bodies, PIE state, or authored physics settings from changing.
Running a second physics world would duplicate hydration, imported-mesh collider cooking,
and hierarchy mapping. Advancing the existing world without isolation would make placement
dependent on every other movable body in the scene and would violate cancel safety.
## Decision
The editor owns Avian's `Time<Physics>` clock:
- normal Edit mode and paused Play mode keep physics paused;
- active Play mode unpauses physics;
- paused Play single-step advances the physics clock by one fixed tick;
- the placement tool advances bounded 120 Hz steps while Edit mode remains paused.
Physics placement is a transactional preview in the shared world. It snapshots the selected
bodies' local transforms and runtime motion components, temporarily makes them dynamic, and
temporarily makes every other movable rigid body static. Static bodies and all colliders stay
in the same world, preserving production collision fidelity. Commit restores every temporary
runtime override and writes only the final local transforms through one grouped history
command. Cancel restores the full snapshot and writes no history.
## Consequences
- Edit mode no longer permits authored dynamic bodies to drift outside undo history.
- Placement uses the exact hydrated collider geometry used by PIE and the standalone game.
- Kinematic and dynamic non-selected bodies behave as fixed collision surfaces during the
preview; simulating interacting piles is deliberately outside this tool's contract.
- A placement session cannot cross the Edit/Play boundary and is cancelled on mode exit.
- Future editor physics previews should reuse this clock ownership and transactional-session
model instead of introducing independent ad hoc simulation loops.

View File

@ -26,6 +26,7 @@ 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 |
| [physics-placement.md](physics-placement.md) | Transactional gravity placement, prerequisites, isolation, commit/cancel, and undo |
| [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 |
@ -34,6 +35,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
| [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/terrain-material-layers/](evaluations/terrain-material-layers/) | Live screenshot and verification record for blended layers, assignment, painting, and history |
| [evaluations/physics-placement/](evaluations/physics-placement/) | Live screenshot and verification record for multi-prop settling, cancel, commit, undo, and redo |
| [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 |
@ -44,6 +46,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
| `lib.rs` / `EditorPluginGroup` | Ordered plugin bundle, lib/bin split | ADR 0008, architecture.md |
| `shared::components::BrushDesc` / `shared::hydration::brushes` | Persisted brush authoring data and generated runtime mesh hydration | brushes.md, ADR 0021 |
| `shared::components::TerrainDesc` / `shared::hydration::terrain` / `blacksite_surface` | Persisted height/layer grids, generated chunks, and raster blending | terrain.md, ADR 0039, ADR 0040 |
| `viewport/physics_placement.rs` / `play/state.rs` | Paused Edit physics and transactional gravity placement | physics-placement.md, ADR 0041 |
| `scene/` | Level I/O, tabs, composition materialization, schema, viewport render-target setup | architecture.md, multi-scene-composition.md, ADR 0026 |
| `viewport/` | Camera, selection, gizmos, render views | architecture.md |
| `play/` | PIE session, editor mode state | architecture.md |

View File

@ -24,6 +24,7 @@ Native-only dependencies (`rfd`, BRP HTTP) stay in `editor`. `settings`, `shared
| `ProjectSettingsPlugin` | Load `assets/project.ron`, emit changes, sync `SimTuning` |
| `ProjectIoPlugin` | User prefs, workspace metadata, window title inputs |
| `EditorStatePlugin` | `EditorMode` (Edit/Play), `PlayPossession`, camera/input gating |
| `PhysicsPlacementPlugin` | Paused Edit physics plus isolated transactional gravity placement |
| `ViewportPlugin` | Grid, focus, bookmarks, snap settings |
| `EditorCameraPlugin` | Fly camera; FX applied via `render_view` + `viewport_camera` sync |
| `ActorIconsPlugin` | Selectable actor root billboard icons |
@ -181,11 +182,19 @@ stateDiagram-v2
|----------|---------|-------------------|-----------------|
| `SimEnabled` | false | true | true |
| `GameInputEnabled` | false | true | false |
| `Time<Physics>` | paused | running | running |
| Editor camera active | true | false | true |
| Player camera active | false | true | false |
On play enter/exit the editor snapshots **player sim state only** (transform, velocity, jump timers). Authored scene entities are not rolled back so in-play edits persist when stopping PIE.
Paused Play also pauses `Time<Physics>`; **F7** advances that clock by exactly one 64 Hz sim tick.
Edit mode keeps the clock paused. `viewport/physics_placement.rs` may advance bounded 120 Hz
preview steps while selected bodies are temporarily dynamic and every other movable body is
temporarily static. Commit records only final local transforms through grouped history; cancel and
the Edit-to-Play boundary restore the complete runtime snapshot. See
[physics-placement.md](physics-placement.md) and [ADR 0041](../adr/0041-transactional-editor-physics-placement.md).
## Scene authoring flow
1. User edits entities with `LevelObject` + reflectable components from `shared`.

View File

@ -0,0 +1,28 @@
# Physics Placement Evaluation
Date: 2026-07-12
![Three selected props settled on the committed placement floor](physics-placement-settled.png)
The native Wayland editor is shown with the committed
`assets/levels/physics_placement_showcase.scn.ron` fixture. Placement Prop A, B, and C were
multi-selected and released from three different heights through the hand/down-arrow action in the
existing horizontal viewport toolbar.
## Acceptance
- All three props used the hydrated Avian cuboid colliders and settled on Placement Floor after
1.5 simulated seconds.
- During preview the HUD reported selection count, simulated time, and Settled state; transform
gizmos and viewport picking did not compete with the modal tool.
- Escape restored every starting transform and left history empty.
- A second run committed with Enter as exactly one `Undo: Move Selection` entry.
- Ctrl+Z restored all three elevated starting transforms; Ctrl+Y restored the settled transforms.
- Opening the fixture immediately after the terrain showcase no longer panicked on a stale
generated terrain-material binding; the scene-switch regression is covered in
`blacksite_surface`.
- The runtime log contained no physics-placement, scene-load, render-pipeline, or GPU validation
error after the stale-binding fix.
Packaged-runtime tests remain intentionally deferred by project-owner request.

Binary file not shown.

View File

@ -31,8 +31,8 @@ another commit, a dirty worktree, or an older package do not transfer to the can
| ID | Requirement | State | Current evidence and gap |
|----|-------------|-------|--------------------------|
| G1 | Project create/open/resume, scene authoring, autosave/recovery, hierarchy, prefab, and asset integrity pass | Partial | Project/recovery/session/multi-scene/prefab implementations are documented in [project launcher](../../project-launcher.md), [session recovery](../../session-recovery.md), [multi-scene composition](../../multi-scene-composition.md), and [prefab authoring](../../prefab-authoring.md). Collaborative file safety `#49` and non-blocking native dialogs `#52` passed live acceptance. Candidate-specific end-to-end reruns remain. |
| G2 | Brush, material, terrain, physics placement, animation, audio, navigation, PIE, and build/package samples pass | Fail | Animation, audio, navigation, Material Library, targeted material drops, and build foundations are implemented. Renderer foundation `#51` remains open on deformed Solari geometry; terrain `#22`-`#24`, physics placement `#25`, and brush acceptance `#37` remain incomplete. Packaged testing is owner-deferred. |
| G3 | Undo/redo/cancel invariants and helper cleanup cover every production mutation path | Partial | The reusable harness and current tool coverage are documented in [operator regression testing](../../operator-regression-testing.md). Gitea `#33` remains open for terrain-stroke and physics-settle fixtures after those tools exist. |
| G2 | Brush, material, terrain, physics placement, animation, audio, navigation, PIE, and build/package samples pass | Fail | Animation, audio, navigation, Material Library, targeted material drops, terrain `#22`-`#24`, physics placement `#25`, and build foundations are implemented with source/live fixtures. Renderer foundation `#51` and brush acceptance `#37` remain incomplete. Packaged testing is owner-deferred. |
| G3 | Undo/redo/cancel invariants and helper cleanup cover every production mutation path | Partial | The reusable harness and current tool coverage are documented in [operator regression testing](../../operator-regression-testing.md). Terrain stroke and physics settle/cancel/undo fixtures now exist; Gitea `#33` remains open for full mutation-path coverage. |
| G4 | Representative project completes an eight-hour soak without unbounded memory/target growth or unrecoverable failure | Missing | No candidate soak log, resource timeline, failure ledger, or target-growth measurement exists. |
| G5 | Cold start, scene open/save, asset refresh, common manipulation, and package-build budgets are documented and measured | Missing | Gitea `#34` is open; no ratified budgets or candidate measurement record exists. |
| G6 | Headless content validation and CI are green from a clean checkout | Missing | [CI configuration](../../../../.github/workflows/ci.yml) exists and local source/headless checks have passed during feature work, but no clean-checkout candidate run is linked. The current Gitea server does not expose an Actions run endpoint for this repository. |
@ -58,7 +58,7 @@ another commit, a dirty worktree, or an older package do not transfer to the can
| Brush blockout/edit/CSG | Implemented foundation; `#37` open | Not signed off |
| Material catalog and assignment | Material Library and exact targeted drops accepted; `#51` open only on deformed Solari geometry | Not rerun as one candidate |
| Terrain authoring | `#22`-`#24` open | Blocked |
| Physics placement | `#25` open | Blocked |
| Physics placement | `#25` source/live acceptance complete | Pass; packaged-runtime acceptance owner-deferred |
| Animation | Implemented | Not rerun |
| Audio | Implemented | Not rerun |
| Navigation | Implemented | Not rerun |

View File

@ -88,6 +88,11 @@ handle in place. Standard and custom Surface changes therefore propagate to ever
or skinned renderer slot without reloading geometry, joints, or the current animation pose. Invalid
Surface edits retain the last-good evaluator generation and emit diagnostics.
Renderer and terrain material bindings target generated draw entities. Scene switches may remove
those entities between binding discovery and deferred command application, so binding replacement
uses fallible entity commands and treats that stale-target race as normal lifecycle cleanup rather
than an editor-fatal error.
## Custom Surface evaluators
A custom shader schema may reference evaluator WGSL. Evaluators return material properties through

View File

@ -0,0 +1,37 @@
# Physics Placement
The Physics Placement tool releases selected authored props into the same Avian collision world
used by Play mode. It is intended for arranging loose objects on floors, terrain, brushes, and
other hydrated colliders without hand-tuning every final transform.
## Workflow
1. Select one or more mutable level objects. Each object needs an enabled **Rigid Body** and an
enabled, non-trigger **Collider** component.
2. Press the hand/down-arrow button in the existing horizontal Scene viewport toolbar.
3. The selected props become dynamic and fall under gravity. The viewport HUD reports simulation
time and whether the group is still moving, settled, or stopped at the preview limit.
4. Press the check button to commit the current transforms, or **Escape** / the X button to cancel.
**Enter** commits after the group has settled.
Commit creates one `Move Selection` undo entry for the whole group. Cancel restores the exact
pre-placement local transforms and runtime motion components and creates no history entry.
## Isolation And Diagnostics
Normal Edit mode keeps Avian physics paused, so authored dynamic bodies cannot drift outside
history. During placement, every non-selected dynamic or kinematic body is temporarily treated as
a static collision surface. The tool restores those bodies exactly when the preview ends. Entering
Play mode cancels an active placement preview.
The start action is non-destructive when prerequisites are missing. The viewport and status strip
identify locked/linked selections, missing or disabled rigid bodies, missing/trigger colliders, and
colliders that are still cooking. A ten-second simulated-time limit stops unstable previews while
leaving explicit commit and cancel available.
`assets/levels/physics_placement_showcase.scn.ron` is the committed acceptance fixture. Select
**Placement Prop A**, **B**, and **C** together, start physics placement, and verify that the three
different shapes settle on **Placement Floor** as one commit/cancel transaction.
The clock and transaction boundary are specified by
[ADR 0041](../adr/0041-transactional-editor-physics-placement.md).

View File

@ -57,6 +57,7 @@ Phased work for the in-process editor (`crates/editor`). Status reflects the cur
| Clean game-view overlay | Done | `G` hides editor chrome, grid, visualizers, proxies, gizmos, and selection outlines |
| Tab cycle overlapping picks | Done | `ViewportPickStack` + Tab in viewport |
| Multi-viewport | Scaffolded | Primary viewport state is separated from render ownership; secondary viewports remain future work |
| Physics placement | Done | Existing horizontal toolbar action; real Avian settling, prerequisite diagnostics, exact cancel, grouped undo, and isolated non-selected bodies |
## Phase 2b — Advanced rendering