feat(runtime): add M1 scene persistence, undo/redo and hotkeys

- load .forge.ron through bevy_forge_core into the live ECS (3D components)
- Ctrl+S syncs and saves the document; custom components are preserved
- transform undo/redo for inspector sliders and gizmo drags (Ctrl+Z/Y)
- F focus, Delete subtree, Ctrl+D duplicate; camera subtrees are guarded
- 66 runtime tests; workspace 412 passed; garden demo docs/screenshots
This commit is contained in:
Emil Shanaty
2026-10-27 23:03:33 +03:00
parent 59212c952f
commit 3eea9c020d
14 changed files with 2369 additions and 16 deletions
Generated
+1
View File
@@ -991,6 +991,7 @@ name = "bevy_forge_runtime"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"bevy", "bevy",
"bevy_forge_core",
] ]
[[package]] [[package]]
+14
View File
@@ -38,6 +38,20 @@ cargo run -p bevy_forge_runtime --example editor_spike
See [docs/m0-spike.md](docs/m0-spike.md) for controls, architecture and M0 limits. The v1 standalone See [docs/m0-spike.md](docs/m0-spike.md) for controls, architecture and M0 limits. The v1 standalone
editor below remains the current released workflow. editor below remains the current released workflow.
### M1 Core — real scene load/save + undo
M1 loads a real `.forge.ron` scene through `bevy_forge_core`, saves edits with Ctrl+S, adds
Transform undo/redo (Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y) and the F focus, Delete and Ctrl+D duplicate
hotkeys.
```bash
FORGE_PROJECT=examples/demo-project \
FORGE_SCENE=scenes/garden.forge.ron \
cargo run -p bevy_forge_runtime --example editor_live
```
See [docs/m1-core.md](docs/m1-core.md) for the garden demo, hotkeys and M1 limits.
## Two viewports ## Two viewports
| 2D scene editing | 3D wireframe preview | | 2D scene editing | 3D wireframe preview |
+2 -1
View File
@@ -7,9 +7,10 @@ license.workspace = true
authors.workspace = true authors.workspace = true
repository.workspace = true repository.workspace = true
publish = false publish = false
description = "Live in-game scene editor plugin for Bevy 0.20 (M0 spike)" description = "Live in-game scene editor plugin for Bevy 0.20 (M0/M1)"
[dependencies] [dependencies]
bevy_forge_core = { path = "../bevy_forge_core" }
# Frozen M0 feature set. Do not add `tonemapping_luts` (ktx2/zstd) — cameras use # Frozen M0 feature set. Do not add `tonemapping_luts` (ktx2/zstd) — cameras use
# `Tonemapping::AcesFitted` instead of the LUT-based default. # `Tonemapping::AcesFitted` instead of the LUT-based default.
bevy = { version = "0.20", default-features = false, features = [ bevy = { version = "0.20", default-features = false, features = [
@@ -0,0 +1,30 @@
//! M1 live editor: the editor plugin on top of `DefaultPlugins`, loading a real
//! `.forge.ron` scene selected through the environment.
//!
//! Run the garden demo from the repository root:
//!
//! ```text
//! FORGE_PROJECT=examples/demo-project \
//! FORGE_SCENE=scenes/garden.forge.ron \
//! cargo run -p bevy_forge_runtime --example editor_live
//! ```
//!
//! `FORGE_SCENE=""` disables scene loading. With no scene (or a load failure)
//! the editor still starts with its placeholder camera and empty hierarchy.
use bevy::{prelude::*, window::WindowResolution};
use bevy_forge_runtime::EditorPlugin;
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "Bevy Forge — M1 Core".into(),
resolution: WindowResolution::new(1440, 900).with_scale_factor_override(1.0),
..default()
}),
..default()
}))
.add_plugins(EditorPlugin)
.run();
}
+78 -6
View File
@@ -20,7 +20,8 @@ use bevy::{
}; };
use crate::{ use crate::{
plugin::{editor_active, Selection}, history::{History, TransformEdit},
plugin::{editor_active, EditorState, Selection},
ui::EditorLayout, ui::EditorLayout,
}; };
@@ -104,6 +105,9 @@ pub struct GizmoDrag {
pub axis: Option<GizmoAxis>, pub axis: Option<GizmoAxis>,
/// `Transform.translation` of the entity when the drag began (parent-local). /// `Transform.translation` of the entity when the drag began (parent-local).
pub start_translation: Vec3, pub start_translation: Vec3,
/// Full `Transform` of the entity when the drag began, used to record one
/// undo entry on release. `None` outside a drag.
pub start_transform: Option<Transform>,
/// First ray/plane hit in world space. /// First ray/plane hit in world space.
pub start_world_hit: Vec3, pub start_world_hit: Vec3,
/// World-space gizmo origin when the drag began. /// World-space gizmo origin when the drag began.
@@ -395,6 +399,7 @@ fn begin_gizmo_drag(
entity: Some(entity), entity: Some(entity),
axis: Some(axis), axis: Some(axis),
start_translation: target_transform.translation, start_translation: target_transform.translation,
start_transform: Some(*target_transform),
start_world_hit, start_world_hit,
origin, origin,
axis_world, axis_world,
@@ -417,7 +422,12 @@ fn update_gizmo_drag(
return; return;
} }
if !mouse.pressed(MouseButton::Left) { if !mouse.pressed(MouseButton::Left) {
drag.clear(); // The release itself is handled by `end_gizmo_drag`, which records the
// single edit for the whole drag. A press state lost without a release
// event (for example a lost window focus) just cancels the stale drag.
if !mouse.just_released(MouseButton::Left) {
drag.clear();
}
return; return;
} }
let (Some(entity), Some(_axis)) = (drag.entity, drag.axis) else { let (Some(entity), Some(_axis)) = (drag.entity, drag.axis) else {
@@ -461,11 +471,47 @@ fn update_gizmo_drag(
transform.translation = translation; transform.translation = translation;
} }
/// Ends the drag on left release. Runs even when the editor is disabled. /// Builds the single history edit for a finished gizmo drag.
fn end_gizmo_drag(mouse: Res<ButtonInput<MouseButton>>, mut drag: ResMut<GizmoDrag>) { ///
if drag.active && mouse.just_released(MouseButton::Left) { /// The translate gizmo only edits `translation`, so a press/release without
drag.clear(); /// movement records nothing; a changed drag records one `before`/`after` edit.
fn finished_drag_edit(
entity: Entity,
before: Transform,
after: Transform,
) -> Option<TransformEdit> {
(before.translation != after.translation).then_some(TransformEdit {
entity,
before,
after,
})
}
/// Ends the drag on left release and records one transform edit if the entity
/// actually moved. Runs even when the editor is disabled (the drag is always
/// cleaned up, but history is only recorded while the editor is enabled).
fn end_gizmo_drag(
mouse: Res<ButtonInput<MouseButton>>,
state: Res<EditorState>,
mut history: ResMut<History>,
transforms: Query<&Transform>,
mut drag: ResMut<GizmoDrag>,
) {
if !drag.active || !mouse.just_released(MouseButton::Left) {
return;
} }
if state.enabled {
if let (Some(entity), Some(before)) = (drag.entity, drag.start_transform) {
if let Ok(after) = transforms.get(entity) {
if let Some(edit) = finished_drag_edit(entity, before, *after) {
history.push(edit);
}
}
}
}
drag.clear();
} }
/// Reads the parent's global rotation for a selected entity, or identity. /// Reads the parent's global rotation for a selected entity, or identity.
@@ -724,4 +770,30 @@ mod tests {
assert_vec3_approx(rebuilt.translation, transform.translation); assert_vec3_approx(rebuilt.translation, transform.translation);
assert_vec3_approx(*rebuilt.forward(), *transform.forward()); assert_vec3_approx(*rebuilt.forward(), *transform.forward());
} }
#[test]
fn drag_state_starts_without_a_start_transform() {
let drag = GizmoDrag::default();
assert!(!drag.active);
assert!(drag.start_transform.is_none());
}
#[test]
fn finished_drag_edit_only_records_translation_changes() {
let entity = Entity::PLACEHOLDER;
let before = Transform::from_xyz(1.0, 2.0, 3.0);
assert!(
finished_drag_edit(entity, before, before).is_none(),
"a press without movement must not record an edit"
);
let mut moved = before;
moved.translation.x += 0.5;
let recorded = finished_drag_edit(entity, before, moved)
.expect("a drag that changed translation records one edit");
assert_eq!(recorded.entity, entity);
assert_eq!(recorded.before, before);
assert_eq!(recorded.after, moved);
}
} }
+397
View File
@@ -0,0 +1,397 @@
//! Transform undo/redo history (T13).
//!
//! M1 undo/redo is Transform-only: the inspector sliders and the translate gizmo
//! record one [`TransformEdit`] per interaction, and `Ctrl+Z` / `Ctrl+Shift+Z` /
//! `Ctrl+Y` apply the recorded `before` / `after` transforms back to the live
//! entities.
//!
//! The history is intentionally a plain two-stack undo model:
//! - [`History::push`] clears the redo stack and caps the undo stack at
//! [`HISTORY_LIMIT`] entries (the oldest edit is dropped);
//! - [`History::undo`] moves one edit from the undo to the redo stack;
//! - [`History::redo`] moves it back.
//!
//! Applying an edit to a missing entity is a no-op (the entry still moves
//! between the stacks), so undo/redo cannot panic after entities are despawned.
use bevy::prelude::*;
use crate::plugin::editor_active;
/// Maximum number of undo entries kept. Pushing past the cap drops the oldest.
pub const HISTORY_LIMIT: usize = 128;
/// One recorded Transform edit of a single entity.
#[derive(Debug, Clone)]
pub struct TransformEdit {
/// Entity whose `Transform` was edited.
pub entity: Entity,
/// `Transform` before the interaction started.
pub before: Transform,
/// `Transform` after the interaction finished.
pub after: Transform,
}
impl TransformEdit {
/// Writes the pre-edit transform into `target` (undo direction).
pub fn apply_before(&self, target: &mut Transform) {
*target = self.before;
}
/// Writes the post-edit transform into `target` (redo direction).
pub fn apply_after(&self, target: &mut Transform) {
*target = self.after;
}
}
/// Two-stack Transform undo/redo history, added by [`HistoryPlugin`].
#[derive(Resource, Debug, Default)]
pub struct History {
undo: Vec<TransformEdit>,
redo: Vec<TransformEdit>,
}
impl History {
/// Records one edit, clears the redo stack and enforces [`HISTORY_LIMIT`].
///
/// Logs `history: recorded {entity}` at info level — once per edit, never
/// per frame.
pub fn push(&mut self, edit: TransformEdit) {
info!("history: recorded {}", edit.entity);
self.redo.clear();
self.undo.push(edit);
if self.undo.len() > HISTORY_LIMIT {
// Amortized cost is irrelevant here: the cap is exceeded by exactly
// one entry per push, i.e. at most one shift per recorded edit.
self.undo.remove(0);
}
}
/// Pops the newest undo entry and moves it onto the redo stack.
pub fn undo(&mut self) -> Option<TransformEdit> {
let edit = self.undo.pop()?;
self.redo.push(edit.clone());
Some(edit)
}
/// Pops the newest redo entry and moves it back onto the undo stack.
pub fn redo(&mut self) -> Option<TransformEdit> {
let edit = self.redo.pop()?;
self.undo.push(edit.clone());
Some(edit)
}
/// Forgets every undo and redo entry.
pub fn clear(&mut self) {
self.undo.clear();
self.redo.clear();
}
}
/// Installs the transform history and its undo/redo hotkeys.
pub struct HistoryPlugin;
impl Plugin for HistoryPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<History>()
.add_systems(Update, history_hotkeys.run_if(editor_active));
}
}
/// Which history operation the current keyboard state requests.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HistoryAction {
/// `Ctrl+Z`.
Undo,
/// `Ctrl+Shift+Z` or `Ctrl+Y`.
Redo,
}
/// Maps the keyboard state to a history action; `None` when no hotkey fired.
fn history_action(keys: &ButtonInput<KeyCode>) -> Option<HistoryAction> {
let ctrl = keys.pressed(KeyCode::ControlLeft) || keys.pressed(KeyCode::ControlRight);
if !ctrl {
return None;
}
let shift = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
if keys.just_pressed(KeyCode::KeyZ) {
return Some(if shift {
HistoryAction::Redo
} else {
HistoryAction::Undo
});
}
if keys.just_pressed(KeyCode::KeyY) {
return Some(HistoryAction::Redo);
}
None
}
/// Applies one undo/redo entry to the live ECS.
///
/// A missing entity (or one without a `Transform`) is skipped; the entry has
/// already been moved between stacks by [`History::undo`] / [`History::redo`].
fn apply_history_entry(
transforms: &mut Query<&mut Transform>,
edit: &TransformEdit,
action: HistoryAction,
) {
let Ok(mut transform) = transforms.get_mut(edit.entity) else {
debug!(
"history: {} skipped missing entity {}",
action_name(action),
edit.entity
);
return;
};
match action {
HistoryAction::Undo => {
edit.apply_before(&mut transform);
info!("history: undo {}", edit.entity);
}
HistoryAction::Redo => {
edit.apply_after(&mut transform);
info!("history: redo {}", edit.entity);
}
}
}
fn action_name(action: HistoryAction) -> &'static str {
match action {
HistoryAction::Undo => "undo",
HistoryAction::Redo => "redo",
}
}
/// `Ctrl+Z` undo, `Ctrl+Shift+Z` / `Ctrl+Y` redo while the editor is enabled.
///
/// Undo/redo never record new edits: they only move entries between the stacks.
fn history_hotkeys(
keys: Res<ButtonInput<KeyCode>>,
mut history: ResMut<History>,
mut transforms: Query<&mut Transform>,
) {
let Some(action) = history_action(&keys) else {
return;
};
let edit = match action {
HistoryAction::Undo => history.undo(),
HistoryAction::Redo => history.redo(),
};
if let Some(edit) = edit {
apply_history_entry(&mut transforms, &edit, action);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn transform_at(x: f32, y: f32, z: f32) -> Transform {
Transform::from_xyz(x, y, z)
}
fn edit(entity: Entity, before: Transform, after: Transform) -> TransformEdit {
TransformEdit {
entity,
before,
after,
}
}
#[test]
fn undo_pops_edits_in_reverse_order_and_redo_replays_them() {
let entity = Entity::PLACEHOLDER;
let mut history = History::default();
let first = edit(
entity,
transform_at(0.0, 0.0, 0.0),
transform_at(1.0, 0.0, 0.0),
);
let second = edit(
entity,
transform_at(1.0, 0.0, 0.0),
transform_at(2.0, 0.0, 0.0),
);
history.push(first.clone());
history.push(second.clone());
let undone = history.undo().expect("second edit should undo first");
assert_eq!(undone.after.translation, Vec3::new(2.0, 0.0, 0.0));
let undone = history.undo().expect("first edit should undo second");
assert_eq!(undone.after.translation, Vec3::new(1.0, 0.0, 0.0));
assert!(history.undo().is_none());
let redone = history.redo().expect("first edit should redo first");
assert_eq!(redone.after.translation, Vec3::new(1.0, 0.0, 0.0));
let redone = history.redo().expect("second edit should redo second");
assert_eq!(redone.after.translation, Vec3::new(2.0, 0.0, 0.0));
assert!(history.redo().is_none());
}
#[test]
fn undo_and_redo_on_empty_history_return_none() {
let mut history = History::default();
assert!(history.undo().is_none());
assert!(history.redo().is_none());
}
#[test]
fn push_clears_the_redo_stack() {
let entity = Entity::PLACEHOLDER;
let mut history = History::default();
history.push(edit(
entity,
transform_at(0.0, 0.0, 0.0),
transform_at(1.0, 0.0, 0.0),
));
history.undo();
assert_eq!(history.redo.len(), 1);
history.push(edit(
entity,
transform_at(5.0, 0.0, 0.0),
transform_at(6.0, 0.0, 0.0),
));
assert!(history.redo.is_empty());
assert_eq!(history.undo.len(), 1);
assert!(history.redo().is_none());
}
#[test]
fn clear_drops_undo_and_redo() {
let entity = Entity::PLACEHOLDER;
let mut history = History::default();
history.push(edit(
entity,
transform_at(0.0, 0.0, 0.0),
transform_at(1.0, 0.0, 0.0),
));
history.push(edit(
entity,
transform_at(1.0, 0.0, 0.0),
transform_at(2.0, 0.0, 0.0),
));
history.undo();
history.clear();
assert!(history.undo.is_empty());
assert!(history.redo.is_empty());
assert!(history.undo().is_none());
assert!(history.redo().is_none());
}
#[test]
fn push_caps_history_at_the_limit_and_drops_the_oldest() {
let entity = Entity::PLACEHOLDER;
let mut history = History::default();
for index in 0..HISTORY_LIMIT + 2 {
let value = index as f32;
history.push(edit(
entity,
transform_at(value, 0.0, 0.0),
transform_at(value + 1.0, 0.0, 0.0),
));
}
assert_eq!(history.undo.len(), HISTORY_LIMIT);
// The oldest two entries (before 0.0 and 1.0) were dropped; undoing to
// the end leaves the edit whose `before` is 2.0.
let mut last_undone = None;
while let Some(entry) = history.undo() {
last_undone = Some(entry);
}
assert_eq!(history.redo.len(), HISTORY_LIMIT);
assert_eq!(
last_undone
.expect("history should contain entries")
.before
.translation,
Vec3::new(2.0, 0.0, 0.0)
);
}
#[test]
fn before_and_after_helpers_apply_to_a_real_transform() {
let mut world = World::new();
let entity = world.spawn(transform_at(9.0, 9.0, 9.0)).id();
let recorded = edit(
entity,
transform_at(1.0, 2.0, 3.0),
transform_at(4.0, 5.0, 6.0),
);
let mut transform = world
.get_mut::<Transform>(entity)
.expect("spawned entity has a Transform");
recorded.apply_before(&mut transform);
assert_eq!(transform.translation, Vec3::new(1.0, 2.0, 3.0));
recorded.apply_after(&mut transform);
assert_eq!(transform.translation, Vec3::new(4.0, 5.0, 6.0));
}
#[test]
fn undo_and_redo_on_a_missing_entity_move_the_entry_without_panicking() {
let mut app = App::new();
app.init_resource::<History>();
app.init_resource::<ButtonInput<KeyCode>>();
app.insert_resource(crate::plugin::EditorState::default());
app.add_plugins(HistoryPlugin);
let missing = Entity::PLACEHOLDER;
app.world_mut().resource_mut::<History>().push(edit(
missing,
transform_at(0.0, 0.0, 0.0),
transform_at(1.0, 0.0, 0.0),
));
// Ctrl+Z: the entry leaves undo, but there is no entity to update.
{
let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
keys.press(KeyCode::ControlLeft);
keys.press(KeyCode::KeyZ);
}
app.update();
assert!(app.world().resource::<History>().undo.is_empty());
assert_eq!(app.world().resource::<History>().redo.len(), 1);
// Ctrl+Shift+Z moves it back; still no entity and no panic.
{
let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
// Without `InputPlugin` nothing clears `pressed` between frames.
keys.reset_all();
keys.press(KeyCode::ControlLeft);
keys.press(KeyCode::ShiftLeft);
keys.press(KeyCode::KeyZ);
}
app.update();
assert_eq!(app.world().resource::<History>().undo.len(), 1);
assert!(app.world().resource::<History>().redo.is_empty());
}
#[test]
fn hotkey_mapping_requires_ctrl_and_distinguishes_redo() {
let mut keys = ButtonInput::<KeyCode>::default();
assert_eq!(history_action(&keys), None);
keys.press(KeyCode::KeyZ);
assert_eq!(history_action(&keys), None, "Ctrl is required");
keys.press(KeyCode::ControlLeft);
assert_eq!(history_action(&keys), Some(HistoryAction::Undo));
keys.press(KeyCode::ShiftLeft);
assert_eq!(history_action(&keys), Some(HistoryAction::Redo));
keys.clear();
keys.press(KeyCode::ControlRight);
keys.press(KeyCode::KeyY);
assert_eq!(history_action(&keys), Some(HistoryAction::Redo));
}
}
+225 -4
View File
@@ -11,11 +11,13 @@ use bevy::{
reflect::{enums::VariantType, PartialReflect, ReflectMut, ReflectRef}, reflect::{enums::VariantType, PartialReflect, ReflectMut, ReflectRef},
text::FontSize, text::FontSize,
ui_widgets::{ ui_widgets::{
Slider, SliderRange, SliderStep, SliderThumb, SliderValue, TrackClick, ValueChange, Slider, SliderDragState, SliderRange, SliderStep, SliderThumb, SliderValue, TrackClick,
ValueChange,
}, },
}; };
use crate::{ use crate::{
history::{History, TransformEdit},
plugin::{editor_active, Selection}, plugin::{editor_active, Selection},
ui::{InspectorPanel, UiTheme}, ui::{InspectorPanel, UiTheme},
}; };
@@ -228,20 +230,84 @@ fn rebuild_inspector(
}); });
} }
/// Applies a slider edit to the selected entity's `Transform` through reflection. /// Pending pre-edit `Transform` for one inspector slider.
///
/// The first event of a drag has `is_final == false`; the last one has
/// `is_final == true`. A click/snap may deliver a single `is_final == true`
/// event, in which case the current transform is captured before applying it.
///
/// Bevy UI's `TrackClick::Snap` currently reports a plain track click only as a
/// non-final event, so [`on_slider_pointer_release`] finalizes that case when
/// the pointer is released without a drag. A real drag ends with a final
/// `ValueChange` from the widget's `PointerDragEnd` handler instead.
///
/// The pending state is a component on the slider entity, so selection changes
/// that rebuild the panel also discard any half-finished interaction.
#[derive(Component, Default)]
struct InspectorPendingEdit {
before: Option<Transform>,
}
impl InspectorPendingEdit {
/// Returns the transform to record as `before`, remembering it for the rest
/// of the interaction.
fn capture_before(&mut self, current: Transform) -> Transform {
*self.before.get_or_insert(current)
}
/// Finishes the interaction after the value was applied: returns one edit
/// only on the final event and only when the transform actually changed.
fn finish(
&mut self,
entity: Entity,
before: Transform,
after: Transform,
is_final: bool,
) -> Option<TransformEdit> {
if !is_final {
return None;
}
self.before = None;
(before != after).then_some(TransformEdit {
entity,
before,
after,
})
}
/// Finalizes a click that the widget reported without a final event.
///
/// Returns one edit when a pending interaction exists and the target
/// transform changed since it started.
fn finish_click(&mut self, entity: Entity, current: Transform) -> Option<TransformEdit> {
let before = self.before.take()?;
(before != current).then_some(TransformEdit {
entity,
before,
after: current,
})
}
}
/// Applies a slider edit to the selected entity's `Transform` through reflection
/// and records the completed interaction in the transform [`History`].
fn on_slider_value_change( fn on_slider_value_change(
change: On<ValueChange<f32>>, change: On<ValueChange<f32>>,
sliders: Query<&InspectorSlider>, mut sliders: Query<(&InspectorSlider, &mut InspectorPendingEdit)>,
mut transforms: Query<&mut Transform>, mut transforms: Query<&mut Transform>,
mut history: ResMut<History>,
mut commands: Commands, mut commands: Commands,
) { ) {
let Ok(slider) = sliders.get(change.source) else { let Ok((slider, mut pending)) = sliders.get_mut(change.source) else {
return; return;
}; };
let Ok(mut transform) = transforms.get_mut(slider.entity) else { let Ok(mut transform) = transforms.get_mut(slider.entity) else {
return; return;
}; };
// Capture before applying the value: for a drag this happens on the first
// non-final event, for a click/snap on the single final event.
let before = pending.capture_before(*transform);
let path = ["translation", slider.axis.name()]; let path = ["translation", slider.axis.name()];
if apply_f32_path(transform.as_reflect_mut(), &path, change.value) { if apply_f32_path(transform.as_reflect_mut(), &path, change.value) {
// Keep the widget's own state in step with the edit; the gizmo-facing // Keep the widget's own state in step with the edit; the gizmo-facing
@@ -250,6 +316,43 @@ fn on_slider_value_change(
.entity(change.source) .entity(change.source)
.insert(SliderValue(change.value)); .insert(SliderValue(change.value));
} }
let after = *transform;
if let Some(edit) = pending.finish(slider.entity, before, after, change.is_final) {
history.push(edit);
}
}
/// Finalizes a click that produced no final `ValueChange` event.
///
/// While a slider drag is running the widget emits its own final event from the
/// drag-end handler, so this observer must not record a second edit; it only
/// handles releases where no drag is in progress.
fn on_slider_pointer_release(
release: On<PointerRelease>,
mut sliders: Query<(
&InspectorSlider,
&mut InspectorPendingEdit,
Option<&SliderDragState>,
)>,
transforms: Query<&Transform>,
mut history: ResMut<History>,
) {
if release.button != PointerButton::Primary {
return;
}
let Ok((slider, mut pending, drag)) = sliders.get_mut(release.entity) else {
return;
};
if drag.is_some_and(|drag| drag.dragging) {
return;
}
let Ok(transform) = transforms.get(slider.entity) else {
return;
};
if let Some(edit) = pending.finish_click(slider.entity, *transform) {
history.push(edit);
}
} }
/// Copies the selected entity's `Transform.translation` into slider state and labels. /// Copies the selected entity's `Transform.translation` into slider state and labels.
@@ -394,8 +497,10 @@ fn spawn_slider_row(
SliderRange::new(SLIDER_MIN, SLIDER_MAX), SliderRange::new(SLIDER_MIN, SLIDER_MAX),
SliderStep(SLIDER_STEP), SliderStep(SLIDER_STEP),
InspectorSlider { entity, axis }, InspectorSlider { entity, axis },
InspectorPendingEdit::default(),
)) ))
.observe(on_slider_value_change) .observe(on_slider_value_change)
.observe(on_slider_pointer_release)
.with_children(|slider| { .with_children(|slider| {
// Background rail. // Background rail.
slider.spawn(( slider.spawn((
@@ -781,4 +886,120 @@ mod tests {
assert!(!apply_f32_path(&mut transform, &["nonexistent"], 1.0)); assert!(!apply_f32_path(&mut transform, &["nonexistent"], 1.0));
assert_eq!(transform, Transform::default()); assert_eq!(transform, Transform::default());
} }
#[test]
fn slider_tracker_records_one_edit_for_a_whole_drag() {
let entity = Entity::PLACEHOLDER;
let mut tracker = InspectorPendingEdit::default();
let start = Transform::from_xyz(0.0, 0.0, 0.0);
let middle = Transform::from_xyz(1.0, 0.0, 0.0);
let end = Transform::from_xyz(2.0, 0.0, 0.0);
// First non-final event captures the pre-drag transform.
let before = tracker.capture_before(start);
assert!(tracker.finish(entity, before, middle, false).is_none());
// Later non-final events reuse the original `before`.
let before = tracker.capture_before(middle);
assert_eq!(before, start);
assert!(tracker.finish(entity, before, end, false).is_none());
// The final event records exactly one edit and resets the tracker.
let before = tracker.capture_before(end);
assert_eq!(before, start);
let recorded = tracker
.finish(entity, before, end, true)
.expect("a changed drag should record one edit");
assert_eq!(recorded.entity, entity);
assert_eq!(recorded.before, start);
assert_eq!(recorded.after, end);
assert!(tracker.before.is_none());
}
#[test]
fn slider_tracker_records_a_single_final_click_edit() {
let entity = Entity::PLACEHOLDER;
let mut tracker = InspectorPendingEdit::default();
let before = Transform::from_xyz(0.0, 0.0, 0.0);
let after = Transform::from_xyz(3.5, 0.0, 0.0);
let captured = tracker.capture_before(before);
let recorded = tracker
.finish(entity, captured, after, true)
.expect("a snap/click with is_final=true should record one edit");
assert_eq!(recorded.before, before);
assert_eq!(recorded.after, after);
assert!(tracker.before.is_none());
}
#[test]
fn slider_tracker_does_not_record_no_op_edits() {
let entity = Entity::PLACEHOLDER;
let mut tracker = InspectorPendingEdit::default();
let unchanged = Transform::from_xyz(1.0, 2.0, 3.0);
let captured = tracker.capture_before(unchanged);
assert!(tracker.finish(entity, captured, unchanged, true).is_none());
assert!(tracker.before.is_none());
}
#[test]
fn slider_tracker_pushes_only_one_entry_into_history() {
let entity = Entity::PLACEHOLDER;
let mut tracker = InspectorPendingEdit::default();
let mut history = History::default();
let start = Transform::from_xyz(0.0, 0.0, 0.0);
let middle = Transform::from_xyz(1.0, 0.0, 0.0);
let end = Transform::from_xyz(2.0, 0.0, 0.0);
for (current, after, is_final) in [
(start, middle, false),
(middle, end, false),
(end, end, true),
] {
let before = tracker.capture_before(current);
if let Some(edit) = tracker.finish(entity, before, after, is_final) {
history.push(edit);
}
}
assert_eq!(history.undo().expect("one recorded edit").after, end);
assert!(history.undo().is_none());
}
#[test]
fn pending_click_is_finalized_on_pointer_release() {
let entity = Entity::PLACEHOLDER;
let mut pending = InspectorPendingEdit::default();
let before = Transform::from_xyz(0.0, 0.0, 0.0);
let after = Transform::from_xyz(1.25, 0.0, 0.0);
// A `TrackClick::Snap` click arrives as a non-final event and updates
// the transform; no final event follows, so the release finalizes it.
let captured = pending.capture_before(before);
assert!(pending.finish(entity, captured, after, false).is_none());
let recorded = pending
.finish_click(entity, after)
.expect("click release should record the pending edit");
assert_eq!(recorded.before, before);
assert_eq!(recorded.after, after);
assert!(pending.finish_click(entity, after).is_none());
// A no-op click records nothing.
let captured = pending.capture_before(after);
assert!(pending.finish(entity, captured, after, false).is_none());
assert!(pending.finish_click(entity, after).is_none());
}
#[test]
fn click_release_without_a_pending_edit_is_a_no_op() {
let entity = Entity::PLACEHOLDER;
let mut pending = InspectorPendingEdit::default();
assert!(pending
.finish_click(entity, Transform::from_xyz(1.0, 0.0, 0.0))
.is_none());
}
} }
+10 -3
View File
@@ -1,8 +1,9 @@
//! Bevy Forge live editor runtime (v2 M0 spike). //! Bevy Forge live editor runtime (v2 live editor, M1 core).
//! //!
//! [`EditorPlugin`] installs the editor resources, the fixed Bevy UI shell //! [`EditorPlugin`] installs the editor resources, the fixed Bevy UI shell
//! (toolbar, hierarchy panel, inspector panel, status bar) and the module plugins //! (toolbar, hierarchy panel, inspector panel, status bar), the scene bridge that
//! that provide hierarchy, inspector, gizmo and screenshot behavior. //! loads a `.forge.ron` scene into the live ECS and the module plugins that
//! provide hierarchy, inspector, gizmo and screenshot behavior.
//! //!
//! The plugin is designed to be added after `DefaultPlugins`: //! The plugin is designed to be added after `DefaultPlugins`:
//! //!
@@ -15,16 +16,22 @@
pub mod gizmo; pub mod gizmo;
pub mod hierarchy; pub mod hierarchy;
pub mod history;
pub mod inspector; pub mod inspector;
pub mod plugin; pub mod plugin;
pub mod scene;
pub mod screenshot; pub mod screenshot;
pub mod shortcuts;
pub mod ui; pub mod ui;
pub use gizmo::GizmoPlugin; pub use gizmo::GizmoPlugin;
pub use hierarchy::HierarchyPlugin; pub use hierarchy::HierarchyPlugin;
pub use history::HistoryPlugin;
pub use inspector::InspectorPlugin; pub use inspector::InspectorPlugin;
pub use plugin::{editor_active, EditorPlugin, EditorState, InspectorSource, Selection}; pub use plugin::{editor_active, EditorPlugin, EditorState, InspectorSource, Selection};
pub use scene::{SceneConfig, SceneDocument, ScenePlugin};
pub use screenshot::ScreenshotPlugin; pub use screenshot::ScreenshotPlugin;
pub use shortcuts::ShortcutsPlugin;
pub use ui::{ pub use ui::{
EditorLayout, EditorRoot, HierarchyPanel, InspectorPanel, StatusBar, Toolbar, UiTheme, EditorLayout, EditorRoot, HierarchyPanel, InspectorPanel, StatusBar, Toolbar, UiTheme,
}; };
+6 -2
View File
@@ -3,8 +3,9 @@
use bevy::prelude::*; use bevy::prelude::*;
use crate::{ use crate::{
gizmo::GizmoPlugin, hierarchy::HierarchyPlugin, inspector::InspectorPlugin, gizmo::GizmoPlugin, hierarchy::HierarchyPlugin, history::HistoryPlugin,
screenshot::ScreenshotPlugin, ui::shell::EditorUiPlugin, inspector::InspectorPlugin, scene::ScenePlugin, screenshot::ScreenshotPlugin,
shortcuts::ShortcutsPlugin, ui::shell::EditorUiPlugin,
}; };
/// Installs the Bevy Forge live editor on top of `DefaultPlugins`. /// Installs the Bevy Forge live editor on top of `DefaultPlugins`.
@@ -20,6 +21,9 @@ impl Plugin for EditorPlugin {
.init_resource::<Selection>() .init_resource::<Selection>()
.add_plugins(( .add_plugins((
EditorUiPlugin, EditorUiPlugin,
ScenePlugin,
ShortcutsPlugin,
HistoryPlugin,
HierarchyPlugin, HierarchyPlugin,
InspectorPlugin, InspectorPlugin,
GizmoPlugin, GizmoPlugin,
+840
View File
@@ -0,0 +1,840 @@
//! Bridge between a `bevy_forge_core` scene document and the live Bevy ECS.
//!
//! M1 scope: load a `.forge.ron` scene from the project selected by environment
//! variables and spawn the supported 3D components. 2D components, custom
//! components and asset-backed meshes are deliberately skipped with a warning;
//! they are outside the M1 slice. Saving, deleting and duplicating live in later
//! tasks that extend this module.
use std::collections::HashMap;
use std::path::PathBuf;
use bevy::{prelude::*, render::view::Tonemapping};
use bevy_forge_core::{
math, ComponentData, EntityId, EntityNode, MaterialData, MeshSource, PrimitiveShape, Project,
ProjectError, SceneDoc, Transform as CoreTransform, VisibilityMode,
};
use crate::plugin::Selection;
/// Environment variable selecting the Bevy project root.
pub const PROJECT_ENV: &str = "FORGE_PROJECT";
/// Environment variable selecting the scene path relative to the project root.
pub const SCENE_ENV: &str = "FORGE_SCENE";
/// Environment variable selecting the save path relative to the project root.
pub const SAVE_PATH_ENV: &str = "FORGE_SAVE_PATH";
/// Default project root, relative to the working directory.
pub const DEFAULT_PROJECT: &str = "examples/demo-project";
/// Default scene path, relative to the project root.
pub const DEFAULT_SCENE: &str = "scenes/garden.forge.ron";
/// Default save path, relative to the project root. Never the garden scene.
pub const DEFAULT_SAVE_PATH: &str = "scenes/edited.forge.ron";
/// Where the live editor loads its scene from.
///
/// Both paths are relative to [`SceneConfig::project_root`], matching
/// [`bevy_forge_core::Project::save_scene`].
#[derive(Resource, Debug, Clone, PartialEq, Eq)]
pub struct SceneConfig {
/// Bevy project root (`Cargo.toml` with a `bevy` dependency).
pub project_root: PathBuf,
/// Scene to load, relative to `project_root`.
pub scene_path: PathBuf,
/// Where Ctrl+S writes the edited scene, relative to `project_root`.
pub save_path: PathBuf,
}
impl SceneConfig {
/// Reads `FORGE_PROJECT` / `FORGE_SCENE` / `FORGE_SAVE_PATH`.
///
/// Returns `None` when both project and scene are unset (so the M0 example
/// stays standalone) or when the user explicitly disabled loading with
/// `FORGE_SCENE=""`. Unset or empty project/save values fall back to the
/// defaults when loading is enabled.
pub fn from_env() -> Option<Self> {
let project = std::env::var(PROJECT_ENV).ok();
let scene = std::env::var(SCENE_ENV).ok();
let save_path = std::env::var(SAVE_PATH_ENV).ok();
Self::from_values(project.as_deref(), scene.as_deref(), save_path.as_deref())
}
/// Pure parser behind [`SceneConfig::from_env`], kept separate so tests do
/// not have to mutate the process environment.
pub fn from_values(
project: Option<&str>,
scene: Option<&str>,
save_path: Option<&str>,
) -> Option<Self> {
// An explicitly empty scene disables loading. Unset project *and* scene
// means "no scene plugin" so the standalone M0 example stays standalone.
if scene == Some("") {
return None;
}
let project = project.filter(|value| !value.is_empty());
let scene = scene.filter(|value| !value.is_empty());
if project.is_none() && scene.is_none() {
return None;
}
Some(Self {
project_root: non_empty_or(project, DEFAULT_PROJECT).into(),
scene_path: non_empty_or(scene, DEFAULT_SCENE).into(),
save_path: non_empty_or(save_path, DEFAULT_SAVE_PATH).into(),
})
}
}
/// The loaded scene plus the ECS entities spawned from it.
#[derive(Resource, Debug)]
pub struct SceneDocument {
/// The authoring document. Later editor edits are written back into it.
pub doc: SceneDoc,
/// The opened project (canonical root and asset directory).
pub project: Project,
/// Loaded scene path, relative to `project.root`.
pub scene_path: PathBuf,
/// Save path, relative to `project.root`.
pub save_path: PathBuf,
/// Document entity id to live ECS entity.
pub entities: HashMap<EntityId, Entity>,
/// Whether the scene was spawned into the ECS.
pub loaded: bool,
}
/// Loads a `.forge.ron` scene at `Startup` when [`SceneConfig`] is present.
pub struct ScenePlugin;
impl Plugin for ScenePlugin {
fn build(&self, app: &mut App) {
if let Some(config) = SceneConfig::from_env() {
app.insert_resource(config);
}
app.add_systems(Startup, load_scene);
}
}
/// Opens the project, loads the document and spawns the supported 3D content.
///
/// A missing project or scene is a warning: the editor keeps running without a
/// [`SceneDocument`] so the M0 fallback camera still works.
fn load_scene(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
config: Option<Res<SceneConfig>>,
selection: Option<ResMut<Selection>>,
) {
let Some(config) = config else {
return;
};
let project = match Project::open(&config.project_root) {
Ok(project) => project,
Err(error) => {
warn!("scene: load failed: {error}");
return;
}
};
let doc = match project.load_scene(project.root.join(&config.scene_path)) {
Ok(doc) => doc,
Err(error) => {
warn!("scene: load failed: {error}");
return;
}
};
warn_skipped_components(&doc);
let spawned = spawn_document(&mut commands, &mut meshes, &mut materials, &doc);
let selected = spawned.first_mesh.or(spawned.first_entity);
if let Some(mut selection) = selection {
selection.0 = selected;
}
info!(
"scene: loaded {} ({} entities, {} roots)",
doc.name,
doc.entities.len(),
doc.roots.len()
);
commands.insert_resource(SceneDocument {
doc,
project,
scene_path: config.scene_path.clone(),
save_path: config.save_path.clone(),
entities: spawned.entities,
loaded: true,
});
}
/// Entities spawned for a document, in document order.
#[derive(Debug, Default)]
struct SpawnedScene {
entities: HashMap<EntityId, Entity>,
first_mesh: Option<Entity>,
first_entity: Option<Entity>,
}
/// Spawns every node of `doc` in depth-first order, linking children to parents.
fn spawn_document(
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
materials: &mut Assets<StandardMaterial>,
doc: &SceneDoc,
) -> SpawnedScene {
let mut spawned = SpawnedScene::default();
for id in doc.depth_first_ids() {
let Some(node) = doc.entity(id) else {
continue;
};
// `depth_first_ids` visits parents before children, so the parent has
// already been spawned by the time a child is mapped.
let parent = node
.parent
.and_then(|parent_id| spawned.entities.get(&parent_id).copied());
let entity = spawn_entity_from_node(commands, meshes, materials, node, parent);
spawned.first_entity.get_or_insert(entity);
if spawned.first_mesh.is_none() && has_primitive_mesh(node) {
spawned.first_mesh = Some(entity);
}
spawned.entities.insert(id, entity);
}
spawned
}
/// Spawns one document node with the M1 component mapping and returns its entity.
///
/// The mapping is shared by the scene loader and by the duplicate action, so both
/// produce identical component sets:
///
/// - `Name`, Bevy `Transform` (core XYZ Euler degrees converted to a quaternion);
/// - primitive `Mesh3d` + `MeshMaterial3d<StandardMaterial>`;
/// - `Camera3d` + `Tonemapping::AcesFitted`;
/// - `PointLight` / `DirectionalLight` with color, intensity and shadows;
/// - `Visibility`;
/// - `ChildOf` when `parent` is set.
///
/// Sprite, Text2d, Camera2d and custom components are skipped (the loader logs
/// one warning per skipped kind). An asset-backed mesh is skipped with a warning.
pub fn spawn_entity_from_node(
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
materials: &mut Assets<StandardMaterial>,
node: &EntityNode,
parent: Option<Entity>,
) -> Entity {
let mut entity = commands.spawn((
Name::new(node.name.clone()),
transform_from_core(node.transform),
));
if let Some(parent) = parent {
entity.insert(ChildOf(parent));
}
for component in &node.components {
match component {
ComponentData::Mesh3d(mesh_data) => match &mesh_data.mesh {
MeshSource::Primitive(shape) => {
entity.insert((
Mesh3d(meshes.add(primitive_mesh(shape))),
MeshMaterial3d(materials.add(material_from_core(&mesh_data.material))),
));
}
MeshSource::Asset(path) => {
warn!(
"scene: skipping mesh asset '{path}' on '{}' (asset meshes are M1+ scope)",
node.name
);
}
},
ComponentData::Camera3d => {
entity.insert((Camera3d::default(), Tonemapping::AcesFitted));
}
ComponentData::PointLight(light) => {
entity.insert(PointLight {
color: color_from_core(light.color),
intensity: light.intensity,
range: light.range,
shadow_maps_enabled: light.shadows,
..default()
});
}
ComponentData::DirectionalLight(light) => {
entity.insert(DirectionalLight {
color: color_from_core(light.color),
illuminance: light.illuminance,
shadow_maps_enabled: light.shadows,
..default()
});
}
ComponentData::Visibility(mode) => {
entity.insert(visibility_from_core(*mode));
}
// Out of M1 scope. The loader warns once per kind before spawning.
ComponentData::Sprite(_)
| ComponentData::Text2d(_)
| ComponentData::Camera2d
| ComponentData::Custom(_) => {}
}
}
entity.id()
}
// ---------------------------------------------------------------------------
// Save / edit sync (T12)
// ---------------------------------------------------------------------------
/// Copies `Name`, `Transform` and `Visibility` edits from the live ECS back into
/// the document.
///
/// Only entities tracked in [`SceneDocument::entities`] that are still alive are
/// touched. These three fields are the only ones the M1 editor edits, so mesh,
/// light and custom component data is left exactly as loaded. Returns the number
/// of tracked entities that were updated.
///
/// The visibility rule is deliberate: an entity only gains an explicit
/// [`ComponentData::Visibility`] when the document already had one or the live
/// value differs from [`VisibilityMode::Inherited`]. This keeps a save of the
/// garden scene from turning every Bevy default visibility into document data.
pub fn sync_doc_from_ecs(
document: &mut SceneDocument,
names: &Query<&Name>,
transforms: &Query<&Transform>,
visibilities: &Query<&Visibility>,
) -> usize {
let mut synced = 0;
for (id, entity) in &document.entities {
let mut touched = false;
if let Ok(name) = names.get(*entity) {
touched |= document.doc.rename(*id, name.as_str());
}
if let Ok(transform) = transforms.get(*entity) {
if let Some(node) = document.doc.entity_mut(*id) {
node.transform = core_transform_from_bevy(transform);
touched = true;
}
}
if let Ok(visibility) = visibilities.get(*entity) {
let mode = visibility_to_core(*visibility);
let had_visibility = document
.doc
.entity(*id)
.is_some_and(|node| node.has_component("visibility"));
if had_visibility || mode != VisibilityMode::Inherited {
touched |= document
.doc
.set_component(*id, ComponentData::Visibility(mode));
}
}
if touched {
synced += 1;
}
}
synced
}
/// Saves the document to [`SceneDocument::save_path`] and logs the result.
///
/// `save_path` is relative to the project root, so a normal run writes
/// `scenes/edited.forge.ron` (or `FORGE_SAVE_PATH`) and never the loaded garden
/// source unless the user explicitly points the variable at it.
pub fn save_scene(document: &SceneDocument) -> Result<PathBuf, ProjectError> {
let path = document
.project
.save_scene(&document.doc, Some(&document.save_path))?;
info!(
"scene: saved {} ({} entities)",
path.display(),
document.doc.entities.len()
);
Ok(path)
}
/// Removes a document subtree and its tracked ECS entities from the map.
///
/// [`SceneDoc::remove_entity`] returns the document ids depth-first (parent
/// before children); the returned ECS entities follow the same order. Callers
/// that despawn the entities should iterate the result in reverse so children
/// are despawned before their parents.
pub fn remove_subtree_entities(document: &mut SceneDocument, id: EntityId) -> Vec<Entity> {
let removed_ids = document.doc.remove_entity(id);
let mut removed_entities = Vec::with_capacity(removed_ids.len());
for removed_id in removed_ids {
if let Some(entity) = document.entities.remove(&removed_id) {
removed_entities.push(entity);
}
}
removed_entities
}
/// Duplicates the document subtree rooted at `id` and spawns the copy.
///
/// The document copy is inserted right after the original among its siblings and
/// every copied node is spawned through [`spawn_entity_from_node`], preserving
/// the same component mapping as the scene loader. Returns the document id of the
/// new root (already mapped in [`SceneDocument::entities`]), or `None` when `id`
/// is unknown.
pub fn duplicate_subtree(
document: &mut SceneDocument,
id: EntityId,
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
materials: &mut Assets<StandardMaterial>,
) -> Option<EntityId> {
let new_root = document.doc.duplicate_entity(id)?;
// `duplicate_entity` copies the whole subtree into the document; collect just
// the new ids in depth-first order so parents are always spawned first.
let new_ids: Vec<EntityId> = document
.doc
.depth_first_ids()
.into_iter()
.filter(|candidate| document.doc.is_ancestor(new_root, *candidate))
.collect();
for new_id in new_ids {
let node = document.doc.entity(new_id)?.clone();
// The copy keeps the original depth, so its parent is either unchanged
// (already mapped) or a copied node spawned earlier in this loop.
let parent = node
.parent
.and_then(|parent_id| document.entities.get(&parent_id).copied());
let entity = spawn_entity_from_node(commands, meshes, materials, &node, parent);
document.entities.insert(new_id, entity);
}
Some(new_root)
}
/// Converts a Bevy transform into the core transform (XYZ Euler degrees).
pub fn core_transform_from_bevy(transform: &Transform) -> CoreTransform {
CoreTransform {
translation: core_vec3(transform.translation),
rotation: math::quat_to_euler_deg(transform.rotation.to_array()),
scale: core_vec3(transform.scale),
}
}
fn core_vec3(value: Vec3) -> bevy_forge_core::Vec3 {
bevy_forge_core::Vec3::new(value.x, value.y, value.z)
}
/// Logs one warning per skipped component kind present in `doc`.
fn warn_skipped_components(doc: &SceneDoc) {
let mut sprite = None;
let mut text2d = None;
let mut camera2d = None;
let mut custom = None;
for node in doc.entities.values() {
for component in &node.components {
match component {
ComponentData::Sprite(_) => {
sprite.get_or_insert(node.name.as_str());
}
ComponentData::Text2d(_) => {
text2d.get_or_insert(node.name.as_str());
}
ComponentData::Camera2d => {
camera2d.get_or_insert(node.name.as_str());
}
ComponentData::Custom(data) => {
custom.get_or_insert((node.name.as_str(), data.type_path.as_str()));
}
_ => {}
}
}
}
if let Some(name) = sprite {
warn!("scene: skipping Sprite on '{name}' (2D content is out of M1 scope)");
}
if let Some(name) = text2d {
warn!("scene: skipping Text2d on '{name}' (2D content is out of M1 scope)");
}
if let Some(name) = camera2d {
warn!("scene: skipping Camera2d on '{name}' (2D content is out of M1 scope)");
}
if let Some((name, type_path)) = custom {
warn!(
"scene: skipping custom component '{type_path}' on '{name}' (custom components are M1+ scope)"
);
}
}
/// Converts a core transform (XYZ Euler degrees) into a Bevy transform.
pub fn transform_from_core(transform: CoreTransform) -> Transform {
let rotation = math::quat_from_euler_deg(transform.rotation);
Transform {
translation: Vec3::new(
transform.translation.x,
transform.translation.y,
transform.translation.z,
),
rotation: Quat::from_array(rotation),
scale: Vec3::new(transform.scale.x, transform.scale.y, transform.scale.z),
}
}
/// Builds the Bevy mesh for a core primitive, using the shape's real dimensions.
fn primitive_mesh(shape: &PrimitiveShape) -> Mesh {
match shape {
PrimitiveShape::Cuboid { size } => Cuboid::new(size.x, size.y, size.z).into(),
PrimitiveShape::Sphere { radius } => Sphere::new(*radius).into(),
PrimitiveShape::Plane { size } => Plane3d::default().mesh().size(size.x, size.y).into(),
PrimitiveShape::Cylinder { radius, height } => Cylinder::new(*radius, *height).into(),
PrimitiveShape::Capsule { radius, length } => Capsule3d::new(*radius, *length).into(),
}
}
/// Builds a `StandardMaterial` from the document's material data.
fn material_from_core(material: &MaterialData) -> StandardMaterial {
StandardMaterial {
base_color: color_from_core(material.base_color),
metallic: material.metallic,
perceptual_roughness: material.perceptual_roughness,
unlit: material.unlit,
..default()
}
}
/// Converts the core sRGB color into a Bevy color.
fn color_from_core(color: bevy_forge_core::Color) -> Color {
Color::srgba(color.r, color.g, color.b, color.a)
}
fn visibility_from_core(mode: VisibilityMode) -> Visibility {
match mode {
VisibilityMode::Inherited => Visibility::Inherited,
VisibilityMode::Visible => Visibility::Visible,
VisibilityMode::Hidden => Visibility::Hidden,
}
}
fn visibility_to_core(visibility: Visibility) -> VisibilityMode {
match visibility {
Visibility::Inherited => VisibilityMode::Inherited,
Visibility::Visible => VisibilityMode::Visible,
Visibility::Hidden => VisibilityMode::Hidden,
}
}
/// Whether the node has a `Mesh3d` component that produces a real Bevy mesh.
fn has_primitive_mesh(node: &EntityNode) -> bool {
node.components.iter().any(|component| {
matches!(
component,
ComponentData::Mesh3d(data) if matches!(data.mesh, MeshSource::Primitive(_))
)
})
}
fn non_empty_or<'a>(value: Option<&'a str>, default: &'a str) -> &'a str {
match value {
Some(value) if !value.is_empty() => value,
_ => default,
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use bevy::math::EulerRot;
use bevy_forge_core::{CustomComponent, CustomField, Mesh3dData, Vec3 as CoreVec3};
use super::*;
#[derive(Resource)]
struct TestDoc(SceneDoc);
#[derive(Resource)]
struct TestSpawn {
entities: HashMap<EntityId, Entity>,
first_mesh: Option<Entity>,
}
fn demo_project_root() -> PathBuf {
PathBuf::from(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../examples/demo-project"
))
}
fn garden_doc() -> (Project, SceneDoc) {
let root = demo_project_root();
let project = Project::open(&root).expect("demo project must open");
let doc = project
.load_scene(project.root.join(DEFAULT_SCENE))
.expect("garden scene must load");
(project, doc)
}
fn spawn_test_doc(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
doc: Res<TestDoc>,
) {
let spawned = spawn_document(&mut commands, &mut meshes, &mut materials, &doc.0);
commands.insert_resource(TestSpawn {
entities: spawned.entities,
first_mesh: spawned.first_mesh,
});
}
fn spawn_app(doc: SceneDoc) -> App {
let mut app = App::new();
app.init_resource::<Assets<Mesh>>()
.init_resource::<Assets<StandardMaterial>>()
.insert_resource(TestDoc(doc))
.add_systems(Startup, spawn_test_doc);
app.update();
app
}
fn component_names(app: &App, entity: Entity) -> BTreeSet<String> {
app.world()
.inspect_entity(entity)
.expect("entity must exist")
.map(|(_, info)| info.name().to_string())
.collect()
}
#[test]
fn config_defaults_and_overrides_parse_without_touching_the_environment() {
// Unset project and scene means "no scene"; this keeps the M0 example standalone.
assert!(SceneConfig::from_values(None, None, None).is_none());
let defaults = SceneConfig::from_values(Some("examples/demo-project"), None, None)
.expect("explicit project enables the default scene");
assert_eq!(defaults.project_root, PathBuf::from(DEFAULT_PROJECT));
assert_eq!(defaults.scene_path, PathBuf::from(DEFAULT_SCENE));
assert_eq!(defaults.save_path, PathBuf::from(DEFAULT_SAVE_PATH));
assert_eq!(defaults.save_path, PathBuf::from("scenes/edited.forge.ron"));
let custom = SceneConfig::from_values(
Some("/tmp/demo"),
Some("scenes/level.forge.ron"),
Some("saves/level.forge.ron"),
)
.expect("explicit values enable loading");
assert_eq!(custom.project_root, PathBuf::from("/tmp/demo"));
assert_eq!(custom.scene_path, PathBuf::from("scenes/level.forge.ron"));
assert_eq!(custom.save_path, PathBuf::from("saves/level.forge.ron"));
// Empty project/save fall back, empty scene disables loading.
let fallbacks = SceneConfig::from_values(Some(""), Some("scenes/x.forge.ron"), Some(""))
.expect("empty project/save are not fatal");
assert_eq!(fallbacks.project_root, PathBuf::from(DEFAULT_PROJECT));
assert_eq!(fallbacks.save_path, PathBuf::from(DEFAULT_SAVE_PATH));
assert!(SceneConfig::from_values(None, Some(""), None).is_none());
}
#[test]
fn garden_scene_loads_nine_entities_and_seven_roots() {
let (project, doc) = garden_doc();
assert_eq!(project.name, "forge-demo");
assert_eq!(doc.name, "Garden Demo");
assert_eq!(doc.entities.len(), 9);
assert_eq!(doc.roots.len(), 7);
assert_eq!(doc.depth_first_ids().len(), 9);
assert!(doc.check_integrity().is_empty());
}
#[test]
fn garden_spawns_meshes_lights_camera_and_materials() {
let (_, doc) = garden_doc();
let mut app = spawn_app(doc);
let world = app.world_mut();
let spawned = world.resource::<TestSpawn>();
assert_eq!(spawned.entities.len(), 9);
// Six primitive meshes and their materials: Ground, Shrub, Flower Stem,
// Flower Bloom, Orbit Crystal, Lantern Post.
assert_eq!(world.resource::<Assets<Mesh>>().len(), 6);
assert_eq!(world.resource::<Assets<StandardMaterial>>().len(), 6);
// First mesh in depth-first order is Ground (Camera and Sun come first).
assert_eq!(spawned.first_mesh, Some(spawned.entities[&3]));
let camera = spawned.entities[&1];
assert_eq!(world.get::<Name>(camera).unwrap().as_str(), "Camera");
assert!(world.get::<Camera3d>(camera).is_some());
assert_eq!(
world.get::<Tonemapping>(camera),
Some(&Tonemapping::AcesFitted)
);
let camera_transform = world.get::<Transform>(camera).unwrap();
assert_eq!(camera_transform.translation, Vec3::new(7.5, 5.0, 12.0));
let expected = Quat::from_euler(
EulerRot::XYZ,
(-16.0f32).to_radians(),
30.0f32.to_radians(),
0.0,
);
assert!(
camera_transform.rotation.abs_diff_eq(expected, 1e-5),
"expected XYZ Euler degrees converted to a quaternion, got {:?}",
camera_transform.rotation
);
let sun = world.get::<DirectionalLight>(spawned.entities[&2]).unwrap();
assert_eq!(sun.illuminance, 11_000.0);
assert!(sun.shadow_maps_enabled);
let glow = world.get::<PointLight>(spawned.entities[&9]).unwrap();
assert_eq!(glow.intensity, 600.0);
assert_eq!(glow.range, 12.0);
assert!(glow.shadow_maps_enabled);
let ground = spawned.entities[&3];
assert!(world.get::<Mesh3d>(ground).is_some());
assert!(world
.get::<MeshMaterial3d<StandardMaterial>>(ground)
.is_some());
// The garden custom component is skipped, but the entity itself and its
// mesh still spawn.
let crystal = spawned.entities[&7];
assert!(world.get::<Mesh3d>(crystal).is_some());
assert_eq!(
world.get::<Name>(crystal).unwrap().as_str(),
"Orbit Crystal"
);
}
#[test]
fn garden_hierarchy_links_parent_and_child_entities() {
let (_, doc) = garden_doc();
let mut app = spawn_app(doc);
let world = app.world_mut();
let spawned = world.resource::<TestSpawn>();
let stem = spawned.entities[&5];
let bloom = spawned.entities[&6];
let post = spawned.entities[&8];
let glow = spawned.entities[&9];
assert_eq!(world.get::<ChildOf>(bloom).map(ChildOf::parent), Some(stem));
assert_eq!(world.get::<ChildOf>(glow).map(ChildOf::parent), Some(post));
assert_eq!(world.get::<ChildOf>(stem), None);
assert_eq!(world.get::<ChildOf>(spawned.entities[&1]), None);
assert!(world.get::<Children>(stem).unwrap().contains(&bloom));
assert!(world.get::<Children>(post).unwrap().contains(&glow));
let root_count = world
.query_filtered::<Entity, (With<Name>, With<Transform>, Without<ChildOf>)>()
.iter(world)
.count();
let entity_count = world
.query_filtered::<Entity, (With<Name>, With<Transform>)>()
.iter(world)
.count();
assert_eq!(root_count, 7);
assert_eq!(entity_count, 9);
}
#[test]
fn custom_component_is_skipped_without_changing_the_component_set() {
let mut doc = SceneDoc::new("Custom Skip");
let with_custom = doc.add_entity("With Custom", None);
let without_custom = doc.add_entity("Without Custom", None);
for id in [with_custom, without_custom] {
doc.add_component(
id,
ComponentData::Mesh3d(Mesh3dData {
mesh: MeshSource::Primitive(PrimitiveShape::Cuboid {
size: CoreVec3::ONE,
}),
material: MaterialData::default(),
}),
);
doc.add_component(id, ComponentData::Visibility(VisibilityMode::Hidden));
}
doc.add_component(
with_custom,
ComponentData::Custom(CustomComponent {
type_path: "forge_demo::Orbit".into(),
fields: vec![CustomField {
name: "radius".into(),
expr: "2.4".into(),
}],
}),
);
let app = spawn_app(doc);
let spawned = app.world().resource::<TestSpawn>();
let a = spawned.entities[&with_custom];
let b = spawned.entities[&without_custom];
assert_eq!(component_names(&app, a), component_names(&app, b));
assert_eq!(
component_names(&app, a).len(),
component_names(&app, b).len()
);
assert!(app.world().get::<Mesh3d>(a).is_some());
assert_eq!(app.world().get::<Visibility>(a), Some(&Visibility::Hidden));
}
#[test]
fn primitive_shapes_spawn_meshes_and_asset_meshes_are_skipped() {
let mut doc = SceneDoc::new("Primitives");
let primitives = [
PrimitiveShape::Cuboid {
size: CoreVec3::new(1.0, 2.0, 3.0),
},
PrimitiveShape::Sphere { radius: 0.5 },
PrimitiveShape::Plane {
size: bevy_forge_core::Vec2::new(4.0, 6.0),
},
PrimitiveShape::Cylinder {
radius: 0.25,
height: 2.0,
},
PrimitiveShape::Capsule {
radius: 0.4,
length: 1.2,
},
];
let mut ids = Vec::new();
for (index, shape) in primitives.into_iter().enumerate() {
let id = doc.add_entity(format!("Primitive {index}"), None);
doc.add_component(
id,
ComponentData::Mesh3d(Mesh3dData {
mesh: MeshSource::Primitive(shape),
material: MaterialData::default(),
}),
);
ids.push(id);
}
let asset = doc.add_entity("Asset Mesh", None);
doc.add_component(
asset,
ComponentData::Mesh3d(Mesh3dData {
mesh: MeshSource::Asset("models/robot.gltf#Mesh0".into()),
material: MaterialData::default(),
}),
);
let mut app = spawn_app(doc);
let world = app.world_mut();
let spawned = world.resource::<TestSpawn>();
for id in &ids {
assert!(
world.get::<Mesh3d>(spawned.entities[id]).is_some(),
"primitive {id} must spawn a Mesh3d"
);
}
assert!(world.get::<Mesh3d>(spawned.entities[&asset]).is_none());
assert_eq!(world.resource::<Assets<Mesh>>().len(), 5);
assert_eq!(world.resource::<Assets<StandardMaterial>>().len(), 5);
}
}
+660
View File
@@ -0,0 +1,660 @@
//! Editor keyboard shortcuts.
//!
//! M1 hotkeys:
//!
//! | Key | Action |
//! |---|---|
//! | `Ctrl+S` | sync the live ECS into the document and save the scene |
//! | `F` | move the [`EditorCamera`] focus to the selected entity |
//! | `Delete` | delete the selected subtree from the ECS and the document |
//! | `Ctrl+D` | duplicate the selected subtree and select the copy |
//!
//! Delete and duplicate are structural edits and are intentionally **not
//! undoable in M1**; only Transform edits from the inspector/gizmo go through
//! [`crate::history`]. All shortcuts are ignored while
//! [`crate::EditorState::enabled`] is false. Actions that need a selection are
//! also ignored when nothing is selected.
use bevy::prelude::*;
use bevy_forge_core::EntityId;
use crate::{
gizmo::{camera_transform, EditorCamera},
plugin::{editor_active, Selection},
scene::{
duplicate_subtree, remove_subtree_entities, save_scene, sync_doc_from_ecs, SceneDocument,
},
};
/// Installs the editor hotkeys.
pub struct ShortcutsPlugin;
impl Plugin for ShortcutsPlugin {
fn build(&self, app: &mut App) {
app.add_systems(
Update,
(
save_scene_shortcut,
focus_selected,
delete_selected,
duplicate_selected,
)
.chain()
.run_if(editor_active),
);
}
}
/// `Ctrl+S`: sync `Name`/`Transform`/`Visibility` then write the scene.
fn save_scene_shortcut(
keys: Res<ButtonInput<KeyCode>>,
document: Option<ResMut<SceneDocument>>,
names: Query<&Name>,
transforms: Query<&Transform>,
visibilities: Query<&Visibility>,
) {
if !ctrl_held(&keys) || !keys.just_pressed(KeyCode::KeyS) {
return;
}
let Some(mut document) = document else {
return;
};
let synced = sync_doc_from_ecs(&mut document, &names, &transforms, &visibilities);
match save_scene(&document) {
Ok(path) => info!(
"shortcut: saved {} ({synced} entities synced)",
path.display()
),
Err(error) => warn!("shortcut: save failed: {error}"),
}
}
/// `F` (without any modifier): focus the editor camera on the selection.
fn focus_selected(
keys: Res<ButtonInput<KeyCode>>,
selection: Res<Selection>,
globals: Query<&GlobalTransform>,
mut cameras: Query<(&mut EditorCamera, &mut Transform)>,
) {
if !keys.just_pressed(KeyCode::KeyF) || any_modifier_held(&keys) {
return;
}
let Some(entity) = selection.0 else {
return;
};
let Ok(global) = globals.get(entity) else {
return;
};
let Ok((mut camera, mut transform)) = cameras.single_mut() else {
return;
};
camera.focus = global.translation();
// Keep the orbit model consistent so the camera actually moves this frame;
// yaw, pitch and distance stay untouched.
*transform = camera_transform(&camera);
info!(
"shortcut: focus {entity} at ({}, {}, {})",
camera.focus.x, camera.focus.y, camera.focus.z
);
}
/// `Delete`: remove the selected subtree from the document, the entity map and
/// the ECS, then clear the selection. Not undoable in M1.
fn delete_selected(
keys: Res<ButtonInput<KeyCode>>,
document: Option<ResMut<SceneDocument>>,
mut selection: ResMut<Selection>,
cameras: Query<(), Or<(With<EditorCamera>, With<Camera3d>)>>,
mut commands: Commands,
) {
if !keys.just_pressed(KeyCode::Delete) {
return;
}
let Some(entity) = selection.0 else {
return;
};
let Some(mut document) = document else {
return;
};
let Some(id) = tracked_id(&document, entity) else {
return;
};
if subtree_contains_camera(&document, id, &cameras) {
warn!("shortcut: refusing to delete camera subtree {id} (editor camera must stay alive)");
return;
}
let removed = remove_subtree_entities(&mut document, id);
// `remove_subtree_entities` returns parent-first document order, so despawn
// in reverse to take the children down before their parents.
for removed_entity in removed.iter().rev() {
commands.entity(*removed_entity).despawn();
}
selection.0 = None;
info!("shortcut: deleted {id} ({} entities)", removed.len());
}
/// `Ctrl+D`: deep-copy the selected subtree in the document and the ECS, then
/// select the copy. Not undoable in M1.
fn duplicate_selected(
keys: Res<ButtonInput<KeyCode>>,
document: Option<ResMut<SceneDocument>>,
mut selection: ResMut<Selection>,
cameras: Query<(), Or<(With<EditorCamera>, With<Camera3d>)>>,
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
if !ctrl_held(&keys) || !keys.just_pressed(KeyCode::KeyD) {
return;
}
let Some(entity) = selection.0 else {
return;
};
let Some(mut document) = document else {
return;
};
let Some(id) = tracked_id(&document, entity) else {
return;
};
if subtree_contains_camera(&document, id, &cameras) {
warn!("shortcut: refusing to duplicate camera subtree {id} (only one editor camera)");
return;
}
let Some(new_root) = duplicate_subtree(
&mut document,
id,
&mut commands,
&mut meshes,
&mut materials,
) else {
return;
};
if let Some(new_entity) = document.entities.get(&new_root).copied() {
selection.0 = Some(new_entity);
}
info!("shortcut: duplicated {id} -> {new_root}");
}
/// Whether `id` or one of its descendants is a camera entity.
///
/// M1 refuses to delete or duplicate a camera subtree: the editor camera is
/// adopted from the scene, so removing it breaks gizmo/camera behavior, and a
/// duplicated `Camera3d` would leave two active cameras.
fn subtree_contains_camera(
document: &SceneDocument,
id: EntityId,
cameras: &Query<(), Or<(With<EditorCamera>, With<Camera3d>)>>,
) -> bool {
document.entities.iter().any(|(other_id, entity)| {
(*other_id == id || document.doc.is_ancestor(id, *other_id)) && cameras.contains(*entity)
})
}
fn ctrl_held(keys: &ButtonInput<KeyCode>) -> bool {
keys.any_pressed([KeyCode::ControlLeft, KeyCode::ControlRight])
}
fn any_modifier_held(keys: &ButtonInput<KeyCode>) -> bool {
keys.any_pressed([
KeyCode::ControlLeft,
KeyCode::ControlRight,
KeyCode::AltLeft,
KeyCode::AltRight,
KeyCode::ShiftLeft,
KeyCode::ShiftRight,
KeyCode::SuperLeft,
KeyCode::SuperRight,
])
}
/// Finds the document id mapped to a live ECS entity.
fn tracked_id(document: &SceneDocument, entity: Entity) -> Option<EntityId> {
document
.entities
.iter()
.find_map(|(id, candidate)| (*candidate == entity).then_some(*id))
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use bevy::math::EulerRot;
use bevy_forge_core::{
ComponentData, CustomComponent, CustomField, Mesh3dData, Project, SceneDoc,
Transform as CoreTransform, VisibilityMode,
};
use super::*;
use crate::{plugin::EditorState, scene::SceneDocument};
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
/// A throwaway Bevy project on disk, removed on drop.
struct TempProject {
root: PathBuf,
}
impl TempProject {
fn new(label: &str) -> Self {
let unique = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
let root = std::env::temp_dir()
.join(format!("forge-t12-{label}-{}-{unique}", std::process::id()));
std::fs::create_dir_all(root.join("scenes")).expect("create temp scenes dir");
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"forge-t12-test\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nbevy = \"0.20\"\n",
)
.expect("write temp Cargo.toml");
Self { root }
}
fn open(&self) -> Project {
Project::open(&self.root).expect("temp project must open")
}
fn manifest(&self) -> String {
std::fs::read_to_string(self.root.join("Cargo.toml")).expect("temp manifest")
}
}
impl Drop for TempProject {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
fn dummy_project() -> Project {
Project {
root: PathBuf::from("/tmp/forge-t12"),
name: "forge-t12-test".into(),
assets_dir: PathBuf::from("/tmp/forge-t12"),
bevy_version: Some("0.20".into()),
}
}
fn scene_document(
project: Project,
doc: SceneDoc,
entities: HashMap<EntityId, Entity>,
) -> SceneDocument {
SceneDocument {
doc,
project,
scene_path: PathBuf::from("scenes/test.forge.ron"),
save_path: PathBuf::from("scenes/out.forge.ron"),
entities,
loaded: true,
}
}
/// An app with the resources the real `DefaultPlugins` app provides for the
/// shortcut systems, minus the window/renderer.
fn headless_app() -> App {
let mut app = App::new();
app.init_resource::<Assets<Mesh>>()
.init_resource::<Assets<StandardMaterial>>()
.init_resource::<ButtonInput<KeyCode>>()
.insert_resource(EditorState::default())
.insert_resource(Selection(None));
app
}
fn press(app: &mut App, keys: impl IntoIterator<Item = KeyCode>) {
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.clear();
for key in keys {
input.press(key);
}
}
fn spawn_named(world: &mut World, name: &'static str, parent: Option<Entity>) -> Entity {
let mut entity = world.spawn((
Name::new(name),
Transform::default(),
GlobalTransform::default(),
));
if let Some(parent) = parent {
entity.insert(ChildOf(parent));
}
entity.id()
}
fn custom_component() -> CustomComponent {
CustomComponent {
type_path: "forge_t12::Marker".into(),
fields: vec![CustomField {
name: "value".into(),
expr: "7".into(),
}],
}
}
fn assert_close(actual: f32, expected: f32) {
assert!(
(actual - expected).abs() < 1e-3,
"expected {expected}, got {actual}"
);
}
#[test]
fn shortcut_focus_sets_editor_camera_focus() {
let mut app = headless_app();
let target = app
.world_mut()
.spawn(GlobalTransform::from_translation(Vec3::new(2.0, 3.0, 4.0)))
.id();
let default_camera = EditorCamera::default();
let camera_entity = app
.world_mut()
.spawn((
default_camera,
Transform::default(),
GlobalTransform::default(),
))
.id();
app.world_mut().resource_mut::<Selection>().0 = Some(target);
app.add_plugins(ShortcutsPlugin);
press(&mut app, [KeyCode::KeyF]);
app.update();
let camera = *app.world().get::<EditorCamera>(camera_entity).unwrap();
assert_eq!(camera.focus, Vec3::new(2.0, 3.0, 4.0));
// Yaw/pitch/distance stay untouched by focus.
assert_eq!(camera.yaw, default_camera.yaw);
assert_eq!(camera.pitch, default_camera.pitch);
assert_eq!(camera.distance, default_camera.distance);
let transform = *app.world().get::<Transform>(camera_entity).unwrap();
assert!(transform
.translation
.abs_diff_eq(camera_transform(&camera).translation, 1e-4));
// `F` must not fire while a modifier is held.
app.world_mut().resource_mut::<Selection>().0 = Some(target);
press(&mut app, [KeyCode::ControlLeft, KeyCode::KeyF]);
app.update();
let camera = *app.world().get::<EditorCamera>(camera_entity).unwrap();
assert_eq!(camera.focus, Vec3::new(2.0, 3.0, 4.0));
}
#[test]
fn shortcut_delete_removes_selected_subtree_from_doc_and_map() {
let mut doc = SceneDoc::new("Delete");
let root = doc.add_entity("Root", None);
let child = doc.add_entity("Child", Some(root));
let sibling = doc.add_entity("Sibling", None);
let mut app = headless_app();
let (root_ecs, child_ecs, sibling_ecs) = {
let world = app.world_mut();
let root_ecs = spawn_named(world, "Root", None);
let child_ecs = spawn_named(world, "Child", Some(root_ecs));
let sibling_ecs = spawn_named(world, "Sibling", None);
(root_ecs, child_ecs, sibling_ecs)
};
let mut entities = HashMap::new();
entities.insert(root, root_ecs);
entities.insert(child, child_ecs);
entities.insert(sibling, sibling_ecs);
app.insert_resource(scene_document(dummy_project(), doc, entities));
app.insert_resource(Selection(Some(root_ecs)));
app.add_plugins(ShortcutsPlugin);
press(&mut app, [KeyCode::Delete]);
app.update();
let document = app.world().resource::<SceneDocument>();
assert!(document.doc.entity(root).is_none());
assert!(document.doc.entity(child).is_none());
assert!(document.doc.entity(sibling).is_some());
assert_eq!(document.entities.len(), 1);
assert_eq!(document.entities.get(&sibling), Some(&sibling_ecs));
assert_eq!(app.world().resource::<Selection>().0, None);
let world = app.world();
assert!(world.get::<Name>(root_ecs).is_none());
assert!(world.get::<Name>(child_ecs).is_none());
assert!(world.get::<Name>(sibling_ecs).is_some());
}
#[test]
fn shortcut_duplicate_deep_copies_subtree_in_doc_and_map_and_selects_copy() {
let mut doc = SceneDoc::new("Duplicate");
let root = doc.add_entity("Root", None);
doc.add_component(root, ComponentData::Mesh3d(Mesh3dData::default()));
doc.add_component(root, ComponentData::Custom(custom_component()));
let child = doc.add_entity("Child", Some(root));
let mut app = headless_app();
let (root_ecs, child_ecs) = {
let world = app.world_mut();
let root_ecs = spawn_named(world, "Root", None);
let child_ecs = spawn_named(world, "Child", Some(root_ecs));
(root_ecs, child_ecs)
};
let mut entities = HashMap::new();
entities.insert(root, root_ecs);
entities.insert(child, child_ecs);
app.insert_resource(scene_document(dummy_project(), doc, entities));
app.insert_resource(Selection(Some(root_ecs)));
app.add_plugins(ShortcutsPlugin);
press(&mut app, [KeyCode::ControlLeft, KeyCode::KeyD]);
app.update();
let document = app.world().resource::<SceneDocument>();
assert_eq!(document.doc.entities.len(), 4);
assert_eq!(document.doc.roots.len(), 2);
let new_root = document.doc.roots[1];
assert_ne!(new_root, root);
let new_child = document.doc.entity(new_root).unwrap().children[0];
assert!(document.doc.is_ancestor(new_root, new_child));
assert_eq!(document.doc.entity(new_root).unwrap().name, "Root Copy");
assert_eq!(document.doc.entity(new_child).unwrap().name, "Child");
assert_eq!(document.entities.len(), 4);
let new_root_ecs = document.entities[&new_root];
let new_child_ecs = document.entities[&new_child];
assert_eq!(document.entities[&root], root_ecs);
assert_ne!(new_root_ecs, root_ecs);
assert_eq!(app.world().resource::<Selection>().0, Some(new_root_ecs));
// Deep copy keeps the custom component and the mesh component data.
let copy = document.doc.entity(new_root).unwrap();
assert_eq!(
copy.component("custom:forge_t12::Marker"),
Some(&ComponentData::Custom(custom_component()))
);
assert!(copy.component("mesh3d").is_some());
let world = app.world();
assert_eq!(
world.get::<Name>(new_root_ecs).unwrap().as_str(),
"Root Copy"
);
assert_eq!(
world.get::<ChildOf>(new_child_ecs).map(ChildOf::parent),
Some(new_root_ecs)
);
assert!(world.get::<Mesh3d>(new_root_ecs).is_some());
assert!(world.get::<Name>(root_ecs).is_some());
}
#[test]
fn shortcut_save_round_trips_ecs_edits_in_a_temp_project() {
let temp = TempProject::new("save");
let project = temp.open();
let manifest_before = temp.manifest();
let mut doc = SceneDoc::new("T12 Round Trip");
let root = doc.add_entity("Root", None);
doc.add_component(root, ComponentData::Mesh3d(Mesh3dData::default()));
doc.add_component(root, ComponentData::Custom(custom_component()));
doc.add_component(root, ComponentData::Visibility(VisibilityMode::Hidden));
let child = doc.add_entity("Child", Some(root));
let mut app = headless_app();
let (root_ecs, child_ecs) = {
let world = app.world_mut();
let root_transform = Transform::from_xyz(1.5, -2.0, 3.25)
.with_rotation(Quat::from_euler(
EulerRot::XYZ,
10.0f32.to_radians(),
20.0f32.to_radians(),
(-30.0f32).to_radians(),
))
.with_scale(Vec3::new(2.0, 1.0, 0.5));
let root_ecs = world
.spawn((
Name::new("Renamed Root"),
root_transform,
Visibility::Hidden,
))
.id();
let child_ecs = world
.spawn((
Name::new("Renamed Child"),
Transform::from_xyz(0.5, 1.0, -1.5),
ChildOf(root_ecs),
))
.id();
(root_ecs, child_ecs)
};
let mut entities = HashMap::new();
entities.insert(root, root_ecs);
entities.insert(child, child_ecs);
app.insert_resource(scene_document(project.clone(), doc, entities));
app.insert_resource(Selection(Some(root_ecs)));
app.add_plugins(ShortcutsPlugin);
press(&mut app, [KeyCode::ControlLeft, KeyCode::KeyS]);
app.update();
let save_path = temp.root.join("scenes/out.forge.ron");
assert!(save_path.is_file(), "Ctrl+S must write the scene file");
let loaded = project
.load_scene(&save_path)
.expect("saved scene must parse");
assert_eq!(loaded.name, "T12 Round Trip");
assert!(loaded.check_integrity().is_empty());
let loaded_root = loaded.entity(root).expect("root must round-trip");
assert_eq!(loaded_root.name, "Renamed Root");
assert_core_transform(
&loaded_root.transform,
Vec3::new(1.5, -2.0, 3.25),
[10.0, 20.0, -30.0],
Vec3::new(2.0, 1.0, 0.5),
);
assert_eq!(
loaded_root.component("visibility"),
Some(&ComponentData::Visibility(VisibilityMode::Hidden))
);
assert_eq!(
loaded_root.component("custom:forge_t12::Marker"),
Some(&ComponentData::Custom(custom_component()))
);
assert!(loaded_root.component("mesh3d").is_some());
let loaded_child = loaded.entity(child).expect("child must round-trip");
assert_eq!(loaded_child.name, "Renamed Child");
assert_core_transform(
&loaded_child.transform,
Vec3::new(0.5, 1.0, -1.5),
[0.0, 0.0, 0.0],
Vec3::ONE,
);
// No visibility component was created just because Bevy has a default
// `Visibility` value.
assert!(loaded_child.components.is_empty());
// Only the requested scene was written; the project manifest is untouched.
let mut scene_files: Vec<_> = std::fs::read_dir(temp.root.join("scenes"))
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect();
scene_files.sort();
assert_eq!(scene_files, vec![std::ffi::OsString::from("out.forge.ron")]);
assert_eq!(temp.manifest(), manifest_before);
}
#[test]
fn shortcut_actions_are_ignored_when_editor_is_disabled() {
let mut doc = SceneDoc::new("Disabled");
let root = doc.add_entity("Root", None);
let mut app = headless_app();
let root_ecs = spawn_named(app.world_mut(), "Root", None);
let mut entities = HashMap::new();
entities.insert(root, root_ecs);
app.insert_resource(scene_document(dummy_project(), doc, entities));
app.insert_resource(Selection(Some(root_ecs)));
app.world_mut().resource_mut::<EditorState>().enabled = false;
app.add_plugins(ShortcutsPlugin);
press(&mut app, [KeyCode::Delete]);
app.update();
assert!(app
.world()
.resource::<SceneDocument>()
.doc
.entity(root)
.is_some());
assert_eq!(app.world().resource::<Selection>().0, Some(root_ecs));
}
#[test]
fn delete_refuses_a_camera_subtree() {
let mut doc = SceneDoc::new("camera-scene");
let id = doc.add_entity("Camera", None);
assert!(doc.set_component(id, ComponentData::Camera3d));
let mut app = headless_app();
let camera = app.world_mut().spawn(Camera3d::default()).id();
let entities = HashMap::from([(id, camera)]);
app.insert_resource(scene_document(dummy_project(), doc, entities));
app.insert_resource(Selection(Some(camera)));
press(&mut app, [KeyCode::Delete]);
app.update();
let document = app.world().resource::<SceneDocument>();
assert!(
document.doc.entity(id).is_some(),
"the camera node must stay in the document"
);
assert_eq!(
document.entities.get(&id),
Some(&camera),
"the camera mapping must stay"
);
assert_eq!(app.world().resource::<Selection>().0, Some(camera));
}
fn assert_core_transform(
transform: &CoreTransform,
translation: Vec3,
rotation_degrees: [f32; 3],
scale: Vec3,
) {
assert_close(transform.translation.x, translation.x);
assert_close(transform.translation.y, translation.y);
assert_close(transform.translation.z, translation.z);
assert_close(transform.rotation.x, rotation_degrees[0]);
assert_close(transform.rotation.y, rotation_degrees[1]);
assert_close(transform.rotation.z, rotation_degrees[2]);
assert_close(transform.scale.x, scale.x);
assert_close(transform.scale.y, scale.y);
assert_close(transform.scale.z, scale.z);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 KiB

+106
View File
@@ -0,0 +1,106 @@
# Bevy Forge v2 — M1 Core
Status: **M1 Core complete and independently verified (`pass_with_risks`)**. M0 is committed at
`59212c9`; M1 turns the M0 spike into a scene-editing slice that reads and writes the existing
`bevy_forge_core` `.forge.ron` format.
![Bevy Forge M1 Core — garden scene](m1-core-1440x900.png)
_1000×640 variant: [m1-core-1000x640.png](m1-core-1000x640.png)._
## What M1 adds
- **Real scene load**: `FORGE_PROJECT` + `FORGE_SCENE` load a `.forge.ron` scene through
`bevy_forge_core` into the live ECS. The garden demo is the reference scene.
- **Save**: Ctrl+S syncs edited transforms/names/visibility back into the scene document and writes
a `.forge.ron` file (`FORGE_SAVE_PATH`, default `scenes/edited.forge.ron`).
- **Transform undo/redo**: Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y for inspector-slider and gizmo-drag
edits.
- **Hotkeys**: Ctrl+S save, F focus camera, Delete delete the selected subtree, Ctrl+D duplicate
the selected subtree.
- Focused tests: **66 runtime tests** (scene 6, shortcuts 6, history 8, inspector 13, gizmo 10,
plus the M0 suite), all green.
- Independent verification: **V11 pass_with_risks** (`.swarm/m1/reviews/V11.md`), including a
post-review delta after the camera guard. Runtime **66 tests**, workspace **412 passed, 2 ignored**.
- Interactive checks done: load 9/7, slider edit → Ctrl+Z → Ctrl+Shift+Z, Ctrl+S with a valid saved
RON (garden source untouched), Ctrl+D duplicate, Delete, F focus, camera delete/duplicate refusal,
no panic.
## Run
```bash
cd bevy-forge
FORGE_PROJECT=examples/demo-project \
FORGE_SCENE=scenes/garden.forge.ron \
cargo run -p bevy_forge_runtime --example editor_live
```
| Variable | Default | Meaning |
|---|---|---|
| `FORGE_PROJECT` | `examples/demo-project` | project root with a `Cargo.toml` containing bevy |
| `FORGE_SCENE` | `scenes/garden.forge.ron` | scene to load, relative to the project root |
| `FORGE_SAVE_PATH` | `scenes/edited.forge.ron` | Ctrl+S output, relative to the project root |
The scene plugin loads when at least one of `FORGE_PROJECT`/`FORGE_SCENE` is set; the command above
sets both. The garden scene has 9 entities / 7 roots (Camera, Sun, Ground, Shrub, Flower Stem, Flower Bloom,
Orbit Crystal, Lantern Post, Lantern Glow). The custom `forge_demo::Orbit` component is skipped in
the live ECS with a warning; all 3D meshes, lights, camera, names, transforms and visibility load.
## Controls
| Input | Action |
|---|---|
| F1 / **Editor (F1)** | Toggle the editor UI |
| Click hierarchy row | Select entity |
| Drag inspector Translation X/Y/Z | Move selected entity |
| Drag gizmo axis | Move selected entity |
| Right/middle drag, wheel | Orbit / pan / zoom camera |
| **Ctrl+S** | Save `.forge.ron` |
| **Ctrl+Z** | Undo transform edit |
| **Ctrl+Shift+Z / Ctrl+Y** | Redo transform edit |
| **F** | Focus camera on selected entity |
| **Delete** | Delete selected subtree |
| **Ctrl+D** | Duplicate selected subtree |
Delete/duplicate are structural edits and are **not undoable in M1**; undo/redo is Transform-only.
## Architecture
```
crates/bevy_forge_runtime/
├── src/scene.rs .forge.ron load, ECS spawn, save/sync, delete/duplicate helpers
├── src/shortcuts.rs Ctrl+S / F / Delete / Ctrl+D
├── src/history.rs transform command stack + Ctrl+Z/Y
├── src/inspector.rs M0 reflection inspector + history capture
├── src/gizmo.rs M0 camera/gizmo + history capture
├── src/hierarchy.rs M0 live tree
└── examples/editor_live.rs
```
The runtime now depends on `bevy_forge_core` (Bevy-free model/persistence). Core `Transform` uses
XYZ Euler degrees; the bridge converts to/from Bevy quaternions with
`bevy_forge_core::math::{quat_from_euler_deg, quat_to_euler_deg}`.
## Limits (M1)
- 3D only in the live bridge; Sprite/Text2d/Camera2d components are skipped (M2).
- Custom components are skipped in ECS but preserved in the document on save.
- Delete/duplicate are not undoable; multi-select/docking/terminal/play mode are out of scope.
- Deleting or duplicating a `Camera3d` subtree is refused with a warning in M1: the editor camera is
adopted from the scene, and a second active camera would break the viewport.
- V11 residual: a synthetic portal drag can be shorter than one Bevy frame, so the live gizmo drag
→ Ctrl+Z path and the inspector click/snap finalizer remain unit/helper-tested rather than
scripted end-to-end. Slider-drag undo is interactively verified.
- The garden `Camera3d` is adopted by the editor camera; do not add a second active camera.
- Saving must be explicit (Ctrl+S); there is no autosave loop yet.
- Hover styling and screenshot determinism: park the pointer outside the window for byte-stable
screenshots (M0 caveat).
- A synthetic portal drag can be shorter than one Bevy frame, so the live gizmo-drag → Ctrl+Z path
is covered by transform helper/unit tests and the unchanged M0 drag code, not by a scripted
mouse hold; the slider undo path is interactively verified.
## Verification
M0 acceptance is in [`m0-spike.md`](m0-spike.md). M1 was independently verified by V11:
`.swarm/m1/reviews/V11.md` (initial and post-fix delta). The exact commands are in
`.swarm/m1/plan.md`; the final report with actual spend is in the local M1 coordination dir.