Add transactional physics placement
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
d1a56f77cc
commit
0e7aed40f1
36
.cursor/plans/physics_placement_2026-07-12.plan.md
Normal file
36
.cursor/plans/physics_placement_2026-07-12.plan.md
Normal 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.
|
||||||
@ -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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
|
|
||||||
@ -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] 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] 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] 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] 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)
|
||||||
|
|||||||
@ -197,6 +197,24 @@
|
|||||||
),
|
),
|
||||||
dependencies: [],
|
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"),
|
id: ("706e604e-1a63-4e97-b35d-eeb1d88fe3a9"),
|
||||||
path: "assets/levels/navigation_authoring_showcase.scn.ron",
|
path: "assets/levels/navigation_authoring_showcase.scn.ron",
|
||||||
|
|||||||
164
assets/levels/physics_placement_showcase.scn.ron
Normal file
164
assets/levels/physics_placement_showcase.scn.ron
Normal 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,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
})
|
||||||
@ -311,11 +311,22 @@ fn sync_terrain_layer_material_bindings(
|
|||||||
);
|
);
|
||||||
handle
|
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
|
commands
|
||||||
.entity(entity)
|
.entity(entity)
|
||||||
.remove::<MeshMaterial3d<StandardMaterial>>()
|
.try_remove::<MeshMaterial3d<StandardMaterial>>()
|
||||||
.insert(MeshMaterial3d(handle));
|
.try_insert(MeshMaterial3d(handle));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn terrain_layer_revision(layers: &[TerrainMaterialLayer]) -> u64 {
|
fn terrain_layer_revision(layers: &[TerrainMaterialLayer]) -> u64 {
|
||||||
@ -469,8 +480,8 @@ fn sync_surface_material_bindings(
|
|||||||
{
|
{
|
||||||
commands
|
commands
|
||||||
.entity(entity)
|
.entity(entity)
|
||||||
.remove::<MeshMaterial3d<SurfaceMaterial>>()
|
.try_remove::<MeshMaterial3d<SurfaceMaterial>>()
|
||||||
.insert(MeshMaterial3d(handle));
|
.try_insert(MeshMaterial3d(handle));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@ -540,8 +551,8 @@ fn sync_surface_material_bindings(
|
|||||||
{
|
{
|
||||||
commands
|
commands
|
||||||
.entity(entity)
|
.entity(entity)
|
||||||
.remove::<MeshMaterial3d<SurfaceMaterial>>()
|
.try_remove::<MeshMaterial3d<SurfaceMaterial>>()
|
||||||
.insert(MeshMaterial3d(handle));
|
.try_insert(MeshMaterial3d(handle));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -565,8 +576,8 @@ fn sync_surface_material_bindings(
|
|||||||
if surface_handle.is_none_or(|current| current.0 != handle) {
|
if surface_handle.is_none_or(|current| current.0 != handle) {
|
||||||
commands
|
commands
|
||||||
.entity(entity)
|
.entity(entity)
|
||||||
.remove::<MeshMaterial3d<StandardMaterial>>()
|
.try_remove::<MeshMaterial3d<StandardMaterial>>()
|
||||||
.insert(MeshMaterial3d(handle));
|
.try_insert(MeshMaterial3d(handle));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -957,6 +968,21 @@ pub fn compose_surface_shader(evaluator: &str) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn rejects_resource_bindings_and_missing_entry() {
|
fn rejects_resource_bindings_and_missing_entry() {
|
||||||
|
|||||||
@ -39,6 +39,7 @@ pub use viewport::brush_tool;
|
|||||||
pub use viewport::camera;
|
pub use viewport::camera;
|
||||||
pub use viewport::gizmos;
|
pub use viewport::gizmos;
|
||||||
pub use viewport::material_drop;
|
pub use viewport::material_drop;
|
||||||
|
pub use viewport::physics_placement;
|
||||||
pub use viewport::render_view;
|
pub use viewport::render_view;
|
||||||
pub use viewport::rendering_diagnostics;
|
pub use viewport::rendering_diagnostics;
|
||||||
pub use viewport::selection;
|
pub use viewport::selection;
|
||||||
@ -68,6 +69,7 @@ use history::EditorHistoryPlugin;
|
|||||||
use infra::EditorInfraPlugin;
|
use infra::EditorInfraPlugin;
|
||||||
use material_drop::MaterialDropPlugin;
|
use material_drop::MaterialDropPlugin;
|
||||||
use operators::OperatorPlugin;
|
use operators::OperatorPlugin;
|
||||||
|
use physics_placement::PhysicsPlacementPlugin;
|
||||||
use play::audio_preview::AudioPreviewPlugin;
|
use play::audio_preview::AudioPreviewPlugin;
|
||||||
use play::PlaySessionPlugin;
|
use play::PlaySessionPlugin;
|
||||||
use project::collaboration::CollaborationPlugin;
|
use project::collaboration::CollaborationPlugin;
|
||||||
@ -119,6 +121,7 @@ impl PluginGroup for EditorPluginGroup {
|
|||||||
.add(AudioPreviewPlugin)
|
.add(AudioPreviewPlugin)
|
||||||
.add(ActorIconsPlugin)
|
.add(ActorIconsPlugin)
|
||||||
.add(MaterialDropPlugin)
|
.add(MaterialDropPlugin)
|
||||||
|
.add(PhysicsPlacementPlugin)
|
||||||
.add(EditorSelectionPlugin)
|
.add(EditorSelectionPlugin)
|
||||||
.add(SelectionOutlinePlugin)
|
.add(SelectionOutlinePlugin)
|
||||||
.add(EditorGizmoPlugin)
|
.add(EditorGizmoPlugin)
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use avian3d::prelude::{Physics, PhysicsTime};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::{CursorGrabMode, CursorOptions, PrimaryWindow};
|
use bevy::window::{CursorGrabMode, CursorOptions, PrimaryWindow};
|
||||||
use bevy_egui::PrimaryEguiContext;
|
use bevy_egui::PrimaryEguiContext;
|
||||||
use game::player::PlayerCamera;
|
use game::player::PlayerCamera;
|
||||||
use game::{GameInputEnabled, GameInputFocused, SimEnabled};
|
use game::{GameInputEnabled, GameInputFocused, SimEnabled};
|
||||||
use protocol::PlayerInputIntent;
|
use protocol::{PlayerInputIntent, SIM_TICK_RATE_HZ};
|
||||||
use sim::{Crouching, Grounded, JumpState, Player, PlayerVelocity};
|
use sim::{Crouching, Grounded, JumpState, Player, PlayerVelocity};
|
||||||
|
|
||||||
use crate::camera::EditorCamera;
|
use crate::camera::EditorCamera;
|
||||||
@ -195,6 +198,7 @@ pub(crate) fn sync_mode_resources_and_cameras(
|
|||||||
mut step: ResMut<PlayStepOnce>,
|
mut step: ResMut<PlayStepOnce>,
|
||||||
mut input_enabled: ResMut<GameInputEnabled>,
|
mut input_enabled: ResMut<GameInputEnabled>,
|
||||||
mut sim_enabled: ResMut<SimEnabled>,
|
mut sim_enabled: ResMut<SimEnabled>,
|
||||||
|
mut physics_time: ResMut<Time<Physics>>,
|
||||||
mut editor_cameras: Query<&mut Camera, With<EditorCamera>>,
|
mut editor_cameras: Query<&mut Camera, With<EditorCamera>>,
|
||||||
mut player_cameras: Query<&mut Camera, (With<PlayerCamera>, Without<EditorCamera>)>,
|
mut player_cameras: Query<&mut Camera, (With<PlayerCamera>, Without<EditorCamera>)>,
|
||||||
mut egui_cameras: Query<
|
mut egui_cameras: Query<
|
||||||
@ -212,9 +216,11 @@ pub(crate) fn sync_mode_resources_and_cameras(
|
|||||||
let ejected = playing && *possession == PlayPossession::Ejected;
|
let ejected = playing && *possession == PlayPossession::Ejected;
|
||||||
let possessed = playing && *possession == PlayPossession::Possessed;
|
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;
|
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;
|
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(
|
fn sync_play_input_focus(
|
||||||
mode: Res<State<EditorMode>>,
|
mode: Res<State<EditorMode>>,
|
||||||
possession: Res<PlayPossession>,
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -20,6 +20,10 @@ 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::physics_placement::{
|
||||||
|
begin_physics_placement, request_physics_placement, PhysicsPlacementPhase,
|
||||||
|
PhysicsPlacementRequest, PhysicsPlacementState,
|
||||||
|
};
|
||||||
use crate::viewport::terrain_paint::{TerrainPaintMode, TerrainPaintState};
|
use crate::viewport::terrain_paint::{TerrainPaintMode, TerrainPaintState};
|
||||||
use crate::viewport::terrain_sculpt::{TerrainSculptMode, TerrainSculptState};
|
use crate::viewport::terrain_sculpt::{TerrainSculptMode, TerrainSculptState};
|
||||||
use crate::viewport::{
|
use crate::viewport::{
|
||||||
@ -146,11 +150,12 @@ pub fn viewport_tab_ui(
|
|||||||
return;
|
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_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_terrain_sculpt_hints(world, ui.ctx(), rect);
|
||||||
scene_view_terrain_paint_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_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);
|
||||||
@ -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 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
|
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 sculpt_active = world.resource::<TerrainSculptState>().active;
|
||||||
let paint_active = world.resource::<TerrainPaintState>().active;
|
let paint_active = world.resource::<TerrainPaintState>().active;
|
||||||
if terrain_selected.is_some() || sculpt_active || paint_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;
|
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(
|
fn scene_options_popover(
|
||||||
world: &mut World,
|
world: &mut World,
|
||||||
ctx: &egui::Context,
|
ctx: &egui::Context,
|
||||||
|
|||||||
@ -8,6 +8,7 @@ pub mod camera;
|
|||||||
pub mod gizmos;
|
pub mod gizmos;
|
||||||
pub mod material_drop;
|
pub mod material_drop;
|
||||||
mod panel;
|
mod panel;
|
||||||
|
pub mod physics_placement;
|
||||||
pub mod render_view;
|
pub mod render_view;
|
||||||
pub mod rendering_diagnostics;
|
pub mod rendering_diagnostics;
|
||||||
pub mod selection;
|
pub mod selection;
|
||||||
@ -26,6 +27,7 @@ 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 physics_placement::{PhysicsPlacementPlugin, PhysicsPlacementState};
|
||||||
pub use terrain_paint::{TerrainPaintMode, TerrainPaintPlugin, TerrainPaintState};
|
pub use terrain_paint::{TerrainPaintMode, TerrainPaintPlugin, TerrainPaintState};
|
||||||
pub use terrain_sculpt::{TerrainSculptMode, TerrainSculptPlugin, TerrainSculptState};
|
pub use terrain_sculpt::{TerrainSculptMode, TerrainSculptPlugin, TerrainSculptState};
|
||||||
pub use viewport_mode::EditorViewportMode;
|
pub use viewport_mode::EditorViewportMode;
|
||||||
|
|||||||
670
crates/editor/src/viewport/physics_placement.rs
Normal file
670
crates/editor/src/viewport/physics_placement.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -23,6 +23,7 @@ use crate::visualizers::EditorVisualizerProxy;
|
|||||||
use bevy_egui::egui;
|
use bevy_egui::egui;
|
||||||
|
|
||||||
use crate::viewport::material_drop::{MaterialDropSet, MaterialDropState};
|
use crate::viewport::material_drop::{MaterialDropSet, MaterialDropState};
|
||||||
|
use crate::viewport::physics_placement::PhysicsPlacementState;
|
||||||
use crate::viewport::scene_view_ray;
|
use crate::viewport::scene_view_ray;
|
||||||
use crate::viewport::terrain_paint::TerrainPaintState;
|
use crate::viewport::terrain_paint::TerrainPaintState;
|
||||||
use crate::viewport::terrain_sculpt::TerrainSculptState;
|
use crate::viewport::terrain_sculpt::TerrainSculptState;
|
||||||
@ -54,6 +55,7 @@ struct PickTargetQueries<'w, 's> {
|
|||||||
material_drop: Option<Res<'w, MaterialDropState>>,
|
material_drop: Option<Res<'w, MaterialDropState>>,
|
||||||
terrain_sculpt: Res<'w, TerrainSculptState>,
|
terrain_sculpt: Res<'w, TerrainSculptState>,
|
||||||
terrain_paint: Res<'w, TerrainPaintState>,
|
terrain_paint: Res<'w, TerrainPaintState>,
|
||||||
|
physics_placement: Option<Res<'w, PhysicsPlacementState>>,
|
||||||
viewport_ui: Res<'w, ViewportUiState>,
|
viewport_ui: Res<'w, ViewportUiState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -129,7 +131,13 @@ fn handle_pick_events(
|
|||||||
for _ in click_events.read() {}
|
for _ in click_events.read() {}
|
||||||
return Ok(());
|
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;
|
viewport_click.0 = None;
|
||||||
for _ in click_events.read() {}
|
for _ in click_events.read() {}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@ -447,6 +455,7 @@ fn sync_gizmo_targets(
|
|||||||
brush_mode: Res<BrushEditMode>,
|
brush_mode: Res<BrushEditMode>,
|
||||||
terrain_sculpt: Res<TerrainSculptState>,
|
terrain_sculpt: Res<TerrainSculptState>,
|
||||||
terrain_paint: Res<TerrainPaintState>,
|
terrain_paint: Res<TerrainPaintState>,
|
||||||
|
physics_placement: Option<Res<PhysicsPlacementState>>,
|
||||||
) {
|
) {
|
||||||
ui_state
|
ui_state
|
||||||
.selected_entities
|
.selected_entities
|
||||||
@ -457,6 +466,9 @@ fn sync_gizmo_targets(
|
|||||||
|| brush_mode.is_element_mode()
|
|| brush_mode.is_element_mode()
|
||||||
|| terrain_sculpt.active
|
|| terrain_sculpt.active
|
||||||
|| terrain_paint.active
|
|| terrain_paint.active
|
||||||
|
|| physics_placement
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(PhysicsPlacementState::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() {
|
||||||
|
|||||||
@ -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 |
|
| [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 |
|
| [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 |
|
| [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
|
## 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/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/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/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/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-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/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/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 |
|
||||||
|
|
||||||
|
|||||||
44
docs/adr/0041-transactional-editor-physics-placement.md
Normal file
44
docs/adr/0041-transactional-editor-physics-placement.md
Normal 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.
|
||||||
|
|
||||||
@ -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 |
|
| [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 |
|
||||||
|
| [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/](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 |
|
||||||
@ -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-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-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/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/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 |
|
||||||
|
|
||||||
@ -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 |
|
| `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::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 |
|
| `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 |
|
| `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 |
|
| `viewport/` | Camera, selection, gizmos, render views | architecture.md |
|
||||||
| `play/` | PIE session, editor mode state | architecture.md |
|
| `play/` | PIE session, editor mode state | architecture.md |
|
||||||
|
|||||||
@ -24,6 +24,7 @@ Native-only dependencies (`rfd`, BRP HTTP) stay in `editor`. `settings`, `shared
|
|||||||
| `ProjectSettingsPlugin` | Load `assets/project.ron`, emit changes, sync `SimTuning` |
|
| `ProjectSettingsPlugin` | Load `assets/project.ron`, emit changes, sync `SimTuning` |
|
||||||
| `ProjectIoPlugin` | User prefs, workspace metadata, window title inputs |
|
| `ProjectIoPlugin` | User prefs, workspace metadata, window title inputs |
|
||||||
| `EditorStatePlugin` | `EditorMode` (Edit/Play), `PlayPossession`, camera/input gating |
|
| `EditorStatePlugin` | `EditorMode` (Edit/Play), `PlayPossession`, camera/input gating |
|
||||||
|
| `PhysicsPlacementPlugin` | Paused Edit physics plus isolated transactional gravity placement |
|
||||||
| `ViewportPlugin` | Grid, focus, bookmarks, snap settings |
|
| `ViewportPlugin` | Grid, focus, bookmarks, snap settings |
|
||||||
| `EditorCameraPlugin` | Fly camera; FX applied via `render_view` + `viewport_camera` sync |
|
| `EditorCameraPlugin` | Fly camera; FX applied via `render_view` + `viewport_camera` sync |
|
||||||
| `ActorIconsPlugin` | Selectable actor root billboard icons |
|
| `ActorIconsPlugin` | Selectable actor root billboard icons |
|
||||||
@ -181,11 +182,19 @@ stateDiagram-v2
|
|||||||
|----------|---------|-------------------|-----------------|
|
|----------|---------|-------------------|-----------------|
|
||||||
| `SimEnabled` | false | true | true |
|
| `SimEnabled` | false | true | true |
|
||||||
| `GameInputEnabled` | false | true | false |
|
| `GameInputEnabled` | false | true | false |
|
||||||
|
| `Time<Physics>` | paused | running | running |
|
||||||
| Editor camera active | true | false | true |
|
| Editor camera active | true | false | true |
|
||||||
| Player camera active | false | true | false |
|
| 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.
|
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
|
## Scene authoring flow
|
||||||
|
|
||||||
1. User edits entities with `LevelObject` + reflectable components from `shared`.
|
1. User edits entities with `LevelObject` + reflectable components from `shared`.
|
||||||
|
|||||||
28
docs/editor/evaluations/physics-placement/README.md
Normal file
28
docs/editor/evaluations/physics-placement/README.md
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# Physics Placement Evaluation
|
||||||
|
|
||||||
|
Date: 2026-07-12
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
BIN
docs/editor/evaluations/physics-placement/physics-placement-settled.png
(Stored with Git LFS)
Normal file
BIN
docs/editor/evaluations/physics-placement/physics-placement-settled.png
(Stored with Git LFS)
Normal file
Binary file not shown.
@ -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 |
|
| 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. |
|
| 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. |
|
| 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). Gitea `#33` remains open for terrain-stroke and physics-settle fixtures after those tools exist. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Animation | Implemented | Not rerun |
|
||||||
| Audio | Implemented | Not rerun |
|
| Audio | Implemented | Not rerun |
|
||||||
| Navigation | Implemented | Not rerun |
|
| Navigation | Implemented | Not rerun |
|
||||||
|
|||||||
@ -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
|
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.
|
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
|
## Custom Surface evaluators
|
||||||
|
|
||||||
A custom shader schema may reference evaluator WGSL. Evaluators return material properties through
|
A custom shader schema may reference evaluator WGSL. Evaluators return material properties through
|
||||||
|
|||||||
37
docs/editor/physics-placement.md
Normal file
37
docs/editor/physics-placement.md
Normal 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).
|
||||||
@ -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 |
|
| 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 |
|
| 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 |
|
| 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
|
## Phase 2b — Advanced rendering
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user