feat(runtime): add M2 live play mode, assets, export and hot reload

- play/pause/stop with a tracked-transform snapshot and validation summary
- asset browser with kind filters and image preview from project assets
- BSN and imperative Rust export through bevy_forge_core codegen
- .forge.ron watcher applying transform/name/visibility edits
- 95 runtime tests; workspace 441 passed; M2 demo docs/screenshots
This commit is contained in:
Emil Shanaty
2026-10-27 23:51:41 +03:00
parent 3eea9c020d
commit 72960eb2d0
11 changed files with 3436 additions and 4 deletions
+13
View File
@@ -52,6 +52,19 @@ 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. See [docs/m1-core.md](docs/m1-core.md) for the garden demo, hotkeys and M1 limits.
### M2 Live — play mode, assets, export, hot reload
M2 adds Play/Pause/Stop with transform snapshot/restore, a project asset browser, BSN/Rust export
and a `.forge.ron` transform watcher.
```bash
FORGE_PROJECT=examples/demo-project \
FORGE_SCENE=scenes/garden.forge.ron \
cargo run -p bevy_forge_runtime --example editor_live
```
See [docs/m2-live.md](docs/m2-live.md) for controls and M2 limits.
## Two viewports ## Two viewports
| 2D scene editing | 3D wireframe preview | | 2D scene editing | 3D wireframe preview |
@@ -12,14 +12,25 @@
//! `FORGE_SCENE=""` disables scene loading. With no scene (or a load failure) //! `FORGE_SCENE=""` disables scene loading. With no scene (or a load failure)
//! the editor still starts with its placeholder camera and empty hierarchy. //! the editor still starts with its placeholder camera and empty hierarchy.
use std::path::PathBuf;
use bevy::{prelude::*, window::WindowResolution}; use bevy::{prelude::*, window::WindowResolution};
use bevy_forge_runtime::EditorPlugin; use bevy_forge_runtime::EditorPlugin;
fn main() { fn main() {
// Let AssetServer resolve project-relative paths (`textures/coin.png`) from the
// demo project's assets directory, not from the runtime crate's own assets.
let project =
std::env::var("FORGE_PROJECT").unwrap_or_else(|_| "examples/demo-project".to_string());
let project = std::fs::canonicalize(&project).unwrap_or_else(|_| PathBuf::from(&project));
if project.join("assets").is_dir() {
std::env::set_var("BEVY_ASSET_ROOT", &project);
}
App::new() App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin { .add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window { primary_window: Some(Window {
title: "Bevy Forge — M1 Core".into(), title: "Bevy Forge — M2 Live".into(),
resolution: WindowResolution::new(1440, 900).with_scale_factor_override(1.0), resolution: WindowResolution::new(1440, 900).with_scale_factor_override(1.0),
..default() ..default()
}), }),
File diff suppressed because it is too large Load Diff
+511
View File
@@ -0,0 +1,511 @@
//! Export the live scene to BSN or imperative Rust (T23, M2 Live).
//!
//! | Shortcut | Format | File |
//! |---|---|---|
//! | `Ctrl+E` | Bevy 0.20 BSN (`bsn!` / `bsn_list!`) | `exports/<slug>-bsn.rs` |
//! | `Ctrl+Shift+E` | Imperative `Commands::spawn` Rust | `exports/<slug>-rust.rs` |
//!
//! The generated code comes from the core exporters
//! ([`bevy_forge_core::codegen::ExportFormat`]); this module only decides when to
//! run them and where to write the result. Before generating, live ECS edits are
//! synced back into the document with [`sync_doc_from_ecs`], so an export matches
//! what the viewport currently shows (the same sync `Ctrl+S` performs).
//!
//! Files are always written below `project.root/exports/`: the slug is sanitised
//! with [`bevy_forge_core::project::slugify`] and the resolved directory is
//! canonicalised and checked against the project root before writing, so a
//! crafted scene name cannot escape the project. Exporting without a loaded
//! [`SceneDocument`] is a no-op.
use std::{
ffi::OsString,
fmt, io,
path::{Component, Path, PathBuf},
};
use bevy::prelude::*;
use bevy_forge_core::{
codegen::{CodegenOptions, ExportFormat},
project::slugify,
CodegenError,
};
use crate::{
plugin::{editor_active, EditorState},
scene::{sync_doc_from_ecs, SceneDocument},
};
/// Project-relative directory that receives generated files.
pub const EXPORTS_DIR: &str = "exports";
/// Paths of the most recent successful exports.
#[derive(Resource, Debug, Default)]
pub struct ExportState {
/// Last file written by `Ctrl+E` (BSN).
pub last_bsn: Option<PathBuf>,
/// Last file written by `Ctrl+Shift+E` (Rust).
pub last_rust: Option<PathBuf>,
}
/// Errors from an export attempt.
#[derive(Debug)]
pub enum ExportError {
/// The core code generator rejected the document.
Codegen(CodegenError),
/// The generated file could not be written.
Io(io::Error),
/// The computed path would leave the project root.
OutsideProject(PathBuf),
}
impl fmt::Display for ExportError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Codegen(error) => write!(f, "code generation failed: {error}"),
Self::Io(error) => write!(f, "write failed: {error}"),
Self::OutsideProject(path) => write!(
f,
"refusing to write outside the project root: {}",
path.display()
),
}
}
}
impl std::error::Error for ExportError {}
impl From<CodegenError> for ExportError {
fn from(error: CodegenError) -> Self {
Self::Codegen(error)
}
}
impl From<io::Error> for ExportError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
/// Installs the `Ctrl+E` / `Ctrl+Shift+E` export hotkeys.
pub struct ExportPlugin;
impl Plugin for ExportPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ExportState>()
// `EditorPlugin` installs this too; initializing it here keeps the
// plugin self-contained for standalone apps and unit tests.
.init_resource::<EditorState>()
.add_systems(Update, export_shortcut.run_if(editor_active));
}
}
/// Resolves the export hotkey; `None` when neither chord was pressed.
fn export_request(keys: &ButtonInput<KeyCode>) -> Option<ExportFormat> {
if !keys.any_pressed([KeyCode::ControlLeft, KeyCode::ControlRight])
|| !keys.just_pressed(KeyCode::KeyE)
{
return None;
}
let shift = keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]);
Some(if shift {
ExportFormat::Rust
} else {
ExportFormat::Bsn
})
}
/// `Ctrl+E` exports BSN; `Ctrl+Shift+E` exports imperative Rust.
///
/// The live ECS is synced into the document first when a [`SceneDocument`]
/// exists; without one the shortcut does nothing.
fn export_shortcut(
keys: Res<ButtonInput<KeyCode>>,
document: Option<ResMut<SceneDocument>>,
mut state: ResMut<ExportState>,
names: Query<&Name>,
transforms: Query<&Transform>,
visibilities: Query<&Visibility>,
) {
let Some(format) = export_request(&keys) else {
return;
};
let Some(mut document) = document else {
return;
};
// Export what the viewport shows, not a stale document.
let synced = sync_doc_from_ecs(&mut document, &names, &transforms, &visibilities);
debug!("export: synced {synced} entities from the live ECS");
match write_export(&document, format) {
Ok((path, bytes)) => {
info!("export: wrote {} ({bytes} bytes)", path.display());
match format {
ExportFormat::Bsn => state.last_bsn = Some(path),
ExportFormat::Rust => state.last_rust = Some(path),
}
}
Err(error) => warn!("export: failed: {error}"),
}
}
/// Generates `format` from the document and writes it below `<root>/exports/`.
///
/// Returns the written path and the number of bytes written.
pub fn write_export(
document: &SceneDocument,
format: ExportFormat,
) -> Result<(PathBuf, usize), ExportError> {
let code = format.generate(&document.doc, &CodegenOptions::default())?;
let path = export_path(&document.project.root, &document.doc.name, format)?;
let Some(directory) = path.parent() else {
return Err(ExportError::OutsideProject(path));
};
std::fs::create_dir_all(directory)?;
// Canonicalise the directory before writing so an in-project symlink cannot
// redirect the export outside the root.
let root = document.project.root.canonicalize()?;
let directory = directory.canonicalize()?;
if !directory.starts_with(&root) {
return Err(ExportError::OutsideProject(directory));
}
let Some(file_name) = path.file_name().map(OsString::from) else {
return Err(ExportError::OutsideProject(path));
};
let path = directory.join(file_name);
std::fs::write(&path, code.as_bytes())?;
Ok((path, code.len()))
}
/// Full path of the generated file for `scene_name` inside `project_root`.
///
/// The name is slugified, so separators and `..` cannot survive into the file
/// path; the result is additionally normalized and checked against the root.
pub fn export_path(
project_root: &Path,
scene_name: &str,
format: ExportFormat,
) -> Result<PathBuf, ExportError> {
let directory = project_root.join(EXPORTS_DIR);
let path = normalize_lexical(&directory.join(export_file_name(scene_name, format)));
if !path.starts_with(project_root) {
return Err(ExportError::OutsideProject(path));
}
Ok(path)
}
/// File name (without the `exports/` directory) for one export format.
pub fn export_file_name(scene_name: &str, format: ExportFormat) -> String {
let tag = match format {
ExportFormat::Bsn => "bsn",
ExportFormat::Rust => "rust",
};
format!("{}-{tag}.rs", slugify(scene_name))
}
/// Lexically removes `.` / `..` components without touching the filesystem.
fn normalize_lexical(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
other => out.push(other.as_os_str()),
}
}
out
}
#[cfg(test)]
mod tests {
use std::{
collections::HashMap,
path::Path,
sync::atomic::{AtomicU64, Ordering},
};
use super::*;
use crate::scene::{SceneDocument, DEFAULT_SCENE};
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-t23-export-{label}-{}-{unique}",
std::process::id()
));
std::fs::create_dir_all(&root).expect("create temp project dir");
std::fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"forge-t23-test\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nbevy = \"0.20\"\n",
)
.expect("write temp Cargo.toml");
Self { root }
}
fn open(&self) -> bevy_forge_core::Project {
bevy_forge_core::Project::open(&self.root).expect("temp project must open")
}
}
impl Drop for TempProject {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
fn demo_project_root() -> PathBuf {
PathBuf::from(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../examples/demo-project"
))
}
fn garden_document(project: bevy_forge_core::Project) -> SceneDocument {
let demo = bevy_forge_core::Project::open(demo_project_root()).expect("demo project");
let doc = demo
.load_scene(demo.root.join(DEFAULT_SCENE))
.expect("garden scene must load");
SceneDocument {
doc,
project,
scene_path: PathBuf::from(DEFAULT_SCENE),
save_path: PathBuf::from("scenes/out.forge.ron"),
entities: HashMap::new(),
loaded: true,
}
}
/// A one-root document named like the garden demo, so the generated BSN
/// uses `bsn!` and the output path is `garden-demo-{bsn,rust}.rs`.
fn single_root_document(project: bevy_forge_core::Project) -> SceneDocument {
use bevy_forge_core::{ComponentData, Mesh3dData, SceneDoc};
let mut doc = SceneDoc::new("Garden Demo");
let cube = doc.add_entity("Export Cube", None);
doc.add_component(cube, ComponentData::Mesh3d(Mesh3dData::default()));
SceneDocument {
doc,
project,
scene_path: PathBuf::from(DEFAULT_SCENE),
save_path: PathBuf::from("scenes/out.forge.ron"),
entities: HashMap::new(),
loaded: true,
}
}
fn export_app(document: Option<SceneDocument>) -> App {
let mut app = App::new();
app.init_resource::<ButtonInput<KeyCode>>()
.init_resource::<EditorState>();
if let Some(document) = document {
app.insert_resource(document);
}
app.add_plugins(ExportPlugin);
app
}
/// Runs one frame and clears `just_pressed`, matching `InputPlugin`.
fn tick(app: &mut App) {
app.update();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.clear();
}
fn press(app: &mut App, keys: impl IntoIterator<Item = KeyCode>) {
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.reset_all();
for key in keys {
input.press(key);
}
}
#[test]
fn generated_bsn_contains_bsn_macro_and_rust_contains_commands() {
// A single-root scene is the case where the core exporter emits `bsn!`;
// multi-root scenes use `bsn_list!` (covered by the garden test below).
let temp = TempProject::new("markers");
let document = single_root_document(temp.open());
let bsn = ExportFormat::Bsn
.generate(&document.doc, &CodegenOptions::default())
.expect("BSN generation must succeed");
assert!(bsn.contains("bsn!"), "BSN export must use the bsn! macro");
assert!(bsn.contains("Export Cube"));
let rust = ExportFormat::Rust
.generate(&document.doc, &CodegenOptions::default())
.expect("Rust generation must succeed");
assert!(rust.contains("Commands"), "Rust export must take Commands");
assert!(rust.contains(".spawn("), "Rust export must spawn entities");
assert!(rust.contains("Export Cube"));
}
#[test]
fn garden_bsn_uses_list_macro_for_multiple_roots() {
let demo = bevy_forge_core::Project::open(demo_project_root()).expect("demo project");
let doc = demo
.load_scene(demo.root.join(DEFAULT_SCENE))
.expect("garden scene must load");
assert!(doc.roots.len() > 1, "the garden scene has several roots");
let bsn = ExportFormat::Bsn
.generate(&doc, &CodegenOptions::default())
.expect("BSN generation must succeed");
assert!(
bsn.contains("bsn_list!"),
"multi-root scenes must use bsn_list!"
);
assert!(bsn.contains("Name(\"Camera\")"));
assert!(bsn.contains("Name(\"Ground\")"));
}
#[test]
fn slug_and_path_safety_keep_exports_inside_the_project() {
let root = Path::new("/tmp/forge-t23/project");
let bsn = export_path(root, "Garden Demo", ExportFormat::Bsn).expect("safe path");
assert_eq!(bsn, root.join("exports/garden-demo-bsn.rs"));
let rust = export_path(root, "Garden Demo", ExportFormat::Rust).expect("safe path");
assert_eq!(rust, root.join("exports/garden-demo-rust.rs"));
// Separators, dots and unicode never reach the file name.
for name in [
"../../etc/passwd",
"..\\..\\windows\\system32",
"My Scene!!",
"***",
" ",
"héllo/wörld",
] {
let path = export_path(root, name, ExportFormat::Bsn).expect("slug must be safe");
assert!(
path.starts_with(root),
"{name:?} must stay below the project root: {path:?}"
);
assert_eq!(
path.parent(),
Some(root.join("exports").as_path()),
"{name:?} must stay in exports/"
);
let file_name = path.file_name().unwrap().to_string_lossy();
assert!(
file_name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '.'),
"unsafe characters in {file_name:?}"
);
assert!(
!file_name.contains(".."),
"dot segments leaked into {path:?}"
);
}
assert_eq!(export_file_name("***", ExportFormat::Bsn), "scene-bsn.rs");
assert_eq!(
export_file_name("../../evil Scene", ExportFormat::Rust),
"evil-scene-rust.rs"
);
}
#[test]
fn ctrl_e_writes_bsn_and_ctrl_shift_e_writes_rust() {
let temp = TempProject::new("write");
let project = temp.open();
let mut app = export_app(Some(single_root_document(project)));
press(&mut app, [KeyCode::ControlLeft, KeyCode::KeyE]);
tick(&mut app);
let bsn_path = {
let state = app.world().resource::<ExportState>();
let path = state
.last_bsn
.clone()
.expect("Ctrl+E must record the BSN path");
assert!(state.last_rust.is_none());
path
};
assert!(bsn_path.starts_with(&temp.root));
assert!(bsn_path.ends_with("exports/garden-demo-bsn.rs"));
let bsn = std::fs::read_to_string(&bsn_path).expect("BSN export file must exist");
assert!(bsn.contains("bsn!"));
assert_eq!(
bsn.len(),
std::fs::metadata(&bsn_path).unwrap().len() as usize
);
press(&mut app, [KeyCode::ControlLeft, KeyCode::KeyE]);
tick(&mut app);
// Without Shift the second press overwrites the BSN file.
let state = app.world().resource::<ExportState>();
assert_eq!(state.last_bsn.as_deref(), Some(bsn_path.as_path()));
press(
&mut app,
[KeyCode::ControlLeft, KeyCode::ShiftLeft, KeyCode::KeyE],
);
tick(&mut app);
let state = app.world().resource::<ExportState>();
let rust_path = state
.last_rust
.clone()
.expect("Ctrl+Shift+E must record the Rust path");
assert!(rust_path.starts_with(&temp.root));
assert!(rust_path.ends_with("exports/garden-demo-rust.rs"));
let rust = std::fs::read_to_string(&rust_path).expect("Rust export file must exist");
assert!(rust.contains("Commands"));
assert!(rust.contains(".spawn("));
// The garden source scene itself is untouched.
let demo = bevy_forge_core::Project::open(demo_project_root()).expect("demo project");
assert!(demo.root.join(DEFAULT_SCENE).is_file());
}
#[test]
fn export_without_a_document_is_a_no_op() {
let mut app = export_app(None);
press(&mut app, [KeyCode::ControlLeft, KeyCode::KeyE]);
tick(&mut app);
press(
&mut app,
[KeyCode::ControlLeft, KeyCode::ShiftLeft, KeyCode::KeyE],
);
tick(&mut app);
let state = app.world().resource::<ExportState>();
assert!(state.last_bsn.is_none());
assert!(state.last_rust.is_none());
}
#[test]
fn export_is_ignored_while_the_editor_is_disabled() {
let temp = TempProject::new("disabled");
let project = temp.open();
let mut app = export_app(Some(garden_document(project)));
app.world_mut().resource_mut::<EditorState>().enabled = false;
press(&mut app, [KeyCode::ControlLeft, KeyCode::KeyE]);
tick(&mut app);
assert!(app.world().resource::<ExportState>().last_bsn.is_none());
assert!(
!temp.root.join("exports/garden-demo-bsn.rs").exists(),
"disabled editor must not write files"
);
}
}
+8
View File
@@ -14,20 +14,27 @@
//! .run(); //! .run();
//! ``` //! ```
pub mod assets;
pub mod export;
pub mod gizmo; pub mod gizmo;
pub mod hierarchy; pub mod hierarchy;
pub mod history; pub mod history;
pub mod inspector; pub mod inspector;
pub mod play;
pub mod plugin; pub mod plugin;
pub mod scene; pub mod scene;
pub mod screenshot; pub mod screenshot;
pub mod shortcuts; pub mod shortcuts;
pub mod ui; pub mod ui;
pub mod watcher;
pub use assets::AssetBrowserPlugin;
pub use export::ExportPlugin;
pub use gizmo::GizmoPlugin; pub use gizmo::GizmoPlugin;
pub use hierarchy::HierarchyPlugin; pub use hierarchy::HierarchyPlugin;
pub use history::HistoryPlugin; pub use history::HistoryPlugin;
pub use inspector::InspectorPlugin; pub use inspector::InspectorPlugin;
pub use play::PlayPlugin;
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 scene::{SceneConfig, SceneDocument, ScenePlugin};
pub use screenshot::ScreenshotPlugin; pub use screenshot::ScreenshotPlugin;
@@ -35,3 +42,4 @@ pub use shortcuts::ShortcutsPlugin;
pub use ui::{ pub use ui::{
EditorLayout, EditorRoot, HierarchyPanel, InspectorPanel, StatusBar, Toolbar, UiTheme, EditorLayout, EditorRoot, HierarchyPanel, InspectorPanel, StatusBar, Toolbar, UiTheme,
}; };
pub use watcher::WatcherPlugin;
+968
View File
@@ -0,0 +1,968 @@
//! Play/pause/stop and the scene validation summary (T21, M2 Live).
//!
//! Play mode is a snapshot/restore demo, not a full game play mode:
//!
//! - entering play or pause from editing snapshots every tracked
//! [`Transform`] once;
//! - while playing, tracked mesh entities rotate around their local Y axis;
//! - pause freezes the scene;
//! - stop restores every snapshotted transform exactly and clears the snapshot,
//! so no live edit is lost.
//!
//! The overlay lives in the top-right corner of the 3D viewport, clear of the
//! inspector. Offsets come from [`EditorLayout`], so it also stays inside the
//! viewport when the window is resized.
//!
//! Shortcuts: `P` toggles play/pause, `Shift+P` or `Escape` stops and restores.
use bevy::{
prelude::*,
text::FontSize,
ui_widgets::{Activate, Button as UiButton},
};
use bevy_forge_core::{validate_scene, Severity};
use crate::{
gizmo::EditorCamera,
plugin::EditorState,
scene::SceneDocument,
ui::{EditorLayout, UiTheme},
};
/// Radians per second applied to tracked meshes while playing.
const PLAY_SPIN_SPEED: f32 = 1.0;
/// Margin between the overlay and the viewport edges, in logical pixels.
const OVERLAY_MARGIN: f32 = 10.0;
/// Width of the overlay panel, in logical pixels.
const OVERLAY_WIDTH: f32 = 230.0;
/// The current play mode.
#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PlayMode {
/// Normal editing: nothing animates.
#[default]
Editing,
/// The scene animates.
Playing,
/// The scene is frozen at its current pose.
Paused,
}
impl PlayMode {
/// Short label used by the overlay.
pub const fn label(self) -> &'static str {
match self {
Self::Editing => "Editing",
Self::Playing => "Playing",
Self::Paused => "Paused",
}
}
}
/// Play mode plus the pre-play transform snapshot.
///
/// The snapshot is taken once, when entering play or pause from
/// [`PlayMode::Editing`], and is kept across play/pause switches. It is only
/// cleared by stop (after restoring it).
#[derive(Resource, Debug, Default)]
pub struct PlayState {
/// Current mode.
pub mode: PlayMode,
/// Pre-play transform snapshot, entity to transform.
pub snapshot: Vec<(Entity, Transform)>,
}
/// Counts from the last [`validate_scene`] run for the loaded document.
#[derive(Resource, Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct ValidationSummary {
/// Number of error-severity issues.
pub errors: usize,
/// Number of warning-severity issues.
pub warnings: usize,
/// Number of info-severity issues.
pub infos: usize,
}
/// Root of the play overlay panel.
#[derive(Component)]
pub struct PlayOverlay;
/// Text node displaying the current [`PlayMode`].
#[derive(Component)]
pub struct PlayModeText;
/// Text node displaying the [`ValidationSummary`] counts.
#[derive(Component)]
pub struct ValidationText;
/// Overlay button that starts or resumes play.
#[derive(Component)]
pub struct PlayButton;
/// Overlay button that pauses the scene.
#[derive(Component)]
pub struct PauseButton;
/// Overlay button that stops and restores the scene.
#[derive(Component)]
pub struct StopButton;
/// Installs play/pause/stop controls, the scene animation and validation.
pub struct PlayPlugin;
impl Plugin for PlayPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<PlayState>()
.init_resource::<ValidationSummary>()
// `EditorUiPlugin` installs these too. Initializing them here keeps
// the plugin self-contained for standalone apps and unit tests.
.init_resource::<UiTheme>()
.init_resource::<EditorLayout>()
.add_systems(Startup, build_play_overlay)
.add_systems(
Update,
(
play_shortcuts,
animate_playing_scene,
validate_scene_document,
update_play_overlay,
sync_play_overlay_visibility,
)
.chain(),
);
}
}
// ---------------------------------------------------------------------------
// Transitions
// ---------------------------------------------------------------------------
/// Handles `P` (toggle play/pause) and `Shift+P` / `Escape` (stop).
fn play_shortcuts(
keys: Res<ButtonInput<KeyCode>>,
document: Option<Res<SceneDocument>>,
mut play: ResMut<PlayState>,
mut transforms: Query<&mut Transform>,
) {
let shift = keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]);
if keys.just_pressed(KeyCode::Escape) || (shift && keys.just_pressed(KeyCode::KeyP)) {
stop_play(&mut play, &mut transforms);
return;
}
if !keys.just_pressed(KeyCode::KeyP) {
return;
}
match play.mode {
PlayMode::Playing => pause_play(&mut play, document.as_deref(), &mut transforms),
PlayMode::Editing | PlayMode::Paused => {
start_or_resume_play(&mut play, document.as_deref(), &mut transforms);
}
}
}
/// Play button: enters play from editing (snapshotting) or resumes from pause.
fn on_play_activate(
_activate: On<Activate>,
document: Option<Res<SceneDocument>>,
mut play: ResMut<PlayState>,
mut transforms: Query<&mut Transform>,
) {
start_or_resume_play(&mut play, document.as_deref(), &mut transforms);
}
/// Pause button: freezes the scene, snapshotting first when editing.
fn on_pause_activate(
_activate: On<Activate>,
document: Option<Res<SceneDocument>>,
mut play: ResMut<PlayState>,
mut transforms: Query<&mut Transform>,
) {
pause_play(&mut play, document.as_deref(), &mut transforms);
}
/// Stop button: restores the snapshot and returns to editing.
fn on_stop_activate(
_activate: On<Activate>,
mut play: ResMut<PlayState>,
mut transforms: Query<&mut Transform>,
) {
stop_play(&mut play, &mut transforms);
}
/// Enters [`PlayMode::Playing`] from editing or paused.
///
/// Entering from editing snapshots every alive tracked transform. Switching
/// from paused to playing keeps the existing snapshot; pressing play while
/// already playing is a no-op.
fn start_or_resume_play(
play: &mut PlayState,
document: Option<&SceneDocument>,
transforms: &mut Query<&mut Transform>,
) {
let Some(document) = document else {
return;
};
match play.mode {
PlayMode::Playing => {}
PlayMode::Paused => {
play.mode = PlayMode::Playing;
info!("play: resumed");
}
PlayMode::Editing => {
play.snapshot = snapshot_transforms(document, transforms);
play.mode = PlayMode::Playing;
info!(
"play: playing ({} transforms snapshotted)",
play.snapshot.len()
);
}
}
}
/// Enters [`PlayMode::Paused`], snapshotting first when coming from editing.
///
/// Switching from playing to paused keeps the existing snapshot.
fn pause_play(
play: &mut PlayState,
document: Option<&SceneDocument>,
transforms: &mut Query<&mut Transform>,
) {
let Some(document) = document else {
return;
};
match play.mode {
PlayMode::Paused => {}
PlayMode::Editing => {
play.snapshot = snapshot_transforms(document, transforms);
play.mode = PlayMode::Paused;
info!(
"play: paused ({} transforms snapshotted)",
play.snapshot.len()
);
}
PlayMode::Playing => {
play.mode = PlayMode::Paused;
info!("play: paused");
}
}
}
/// Restores every snapshotted transform, clears the snapshot and goes back to
/// editing.
///
/// Entities that were despawned while playing are skipped. When the scene is
/// already in editing mode with no snapshot, this is a no-op.
fn stop_play(play: &mut PlayState, transforms: &mut Query<&mut Transform>) {
if play.mode == PlayMode::Editing && play.snapshot.is_empty() {
return;
}
let mut restored = 0;
for (entity, transform) in std::mem::take(&mut play.snapshot) {
if let Ok(mut current) = transforms.get_mut(entity) {
*current = transform;
restored += 1;
}
}
play.mode = PlayMode::Editing;
info!("play: stopped (restored {restored} transforms)");
}
/// Copies every alive tracked transform, sorted by entity for determinism.
fn snapshot_transforms(
document: &SceneDocument,
transforms: &Query<&mut Transform>,
) -> Vec<(Entity, Transform)> {
let mut snapshot: Vec<(Entity, Transform)> = document
.entities
.values()
.filter_map(|entity| {
transforms
.get(*entity)
.ok()
.map(|transform| (*entity, *transform))
})
.collect();
snapshot.sort_by_key(|(entity, _)| *entity);
snapshot
}
// ---------------------------------------------------------------------------
// Animation
// ---------------------------------------------------------------------------
/// Rotates tracked mesh entities around their local Y axis while playing.
///
/// The ground plane, cameras (scene or editor) and lights are skipped. Entities
/// that are not mapped from [`SceneDocument::entities`] are skipped too, so
/// editor-owned meshes can never be animated. Editing and pause animate
/// nothing.
fn animate_playing_scene(
time: Res<Time>,
play: Res<PlayState>,
document: Option<Res<SceneDocument>>,
mut meshes: Query<
(Entity, &Name, &mut Transform),
(
With<Mesh3d>,
Without<Camera3d>,
Without<EditorCamera>,
Without<PointLight>,
Without<DirectionalLight>,
),
>,
) {
if play.mode != PlayMode::Playing {
return;
}
let Some(document) = document else {
return;
};
let delta = time.delta_secs() * PLAY_SPIN_SPEED;
if delta == 0.0 {
return;
}
for (entity, name, mut transform) in &mut meshes {
if name.as_str() == "Ground" {
continue;
}
if !document.entities.values().any(|tracked| *tracked == entity) {
continue;
}
transform.rotate_y(delta);
}
}
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
/// Runs [`validate_scene`] once whenever the document appears or changes.
fn validate_scene_document(
document: Option<Res<SceneDocument>>,
mut summary: ResMut<ValidationSummary>,
) {
let Some(document) = document else {
return;
};
if !document.is_changed() {
return;
}
let report = validate_scene(&document.doc, None);
*summary = ValidationSummary {
errors: report.errors(),
warnings: report.warnings(),
infos: report.count(Severity::Info),
};
info!(
"play: validation {} errors/{} warnings",
summary.errors, summary.warnings
);
}
// ---------------------------------------------------------------------------
// Overlay
// ---------------------------------------------------------------------------
/// Builds the top-right viewport overlay.
///
/// The panel is a sibling of the editor shell root: it is positioned inside
/// [`EditorLayout::viewport_rect`] instead of overlapping the inspector, and it
/// hides itself when [`EditorState`] is disabled.
fn build_play_overlay(mut commands: Commands, theme: Res<UiTheme>, layout: Res<EditorLayout>) {
let panel = (
PlayOverlay,
Node {
position_type: PositionType::Absolute,
top: px(layout.top + OVERLAY_MARGIN),
right: px(layout.right + OVERLAY_MARGIN),
width: px(OVERLAY_WIDTH),
flex_direction: FlexDirection::Column,
padding: UiRect::all(px(8)),
row_gap: px(6),
border: UiRect::all(px(1)),
..default()
},
BackgroundColor(theme.panel_bg),
BorderColor::all(theme.border),
);
commands.spawn(panel).with_children(|overlay| {
overlay
.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: px(6),
..default()
})
.with_children(|row| {
row.spawn(label("Play", 13.0, theme.text));
row.spawn((PlayModeText, label("Mode: Editing", 12.0, theme.accent)));
});
overlay
.spawn(Node {
flex_direction: FlexDirection::Row,
column_gap: px(4),
..default()
})
.with_children(|row| {
row.spawn((PlayButton, UiButton, button_node(&theme)))
.observe(on_play_activate)
.with_children(|button| {
button.spawn(label("Play", 12.0, theme.text));
});
row.spawn((PauseButton, UiButton, button_node(&theme)))
.observe(on_pause_activate)
.with_children(|button| {
button.spawn(label("Pause", 12.0, theme.text));
});
row.spawn((StopButton, UiButton, button_node(&theme)))
.observe(on_stop_activate)
.with_children(|button| {
button.spawn(label("Stop", 12.0, theme.danger));
});
});
overlay.spawn((
ValidationText,
label("Errors: 0 Warnings: 0 Infos: 0", 11.0, theme.text_muted),
));
overlay.spawn(label("P play/pause · Shift+P stop", 10.0, theme.text_muted));
});
}
/// Node bundle shared by the three overlay buttons.
fn button_node(theme: &UiTheme) -> impl Bundle {
(
Node {
height: px(24),
flex_grow: 1.0,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border: UiRect::all(px(1)),
..default()
},
BackgroundColor(theme.panel_bg_alt),
BorderColor::all(theme.border),
)
}
/// Builds a UI label with the split `Text` / `TextFont` / `TextColor` API.
fn label(value: impl Into<String>, size: f32, color: Color) -> impl Bundle {
(
Text::new(value.into()),
TextFont {
font: FontSource::default(),
font_size: FontSize::Px(size),
..default()
},
TextColor(color),
)
}
/// Updates the mode and validation labels when either resource changes.
fn update_play_overlay(
play: Res<PlayState>,
summary: Res<ValidationSummary>,
theme: Res<UiTheme>,
mut mode_texts: Query<
(&mut Text, &mut TextColor),
(With<PlayModeText>, Without<ValidationText>),
>,
mut validation_texts: Query<&mut Text, (With<ValidationText>, Without<PlayModeText>)>,
) {
if !play.is_changed() && !summary.is_changed() {
return;
}
let mode = format!("Mode: {}", play.mode.label());
let mode_color = match play.mode {
PlayMode::Editing => theme.accent,
PlayMode::Playing => Color::srgb(0.45, 0.85, 0.55),
PlayMode::Paused => Color::srgb(0.96, 0.75, 0.35),
};
for (mut text, mut color) in &mut mode_texts {
if text.0 != mode {
text.0 = mode.clone();
}
if color.0 != mode_color {
color.0 = mode_color;
}
}
let validation = format!(
"Errors: {} Warnings: {} Infos: {}",
summary.errors, summary.warnings, summary.infos
);
for mut text in &mut validation_texts {
if text.0 != validation {
text.0 = validation.clone();
}
}
}
/// Hides the overlay while the editor is disabled (`F1`).
fn sync_play_overlay_visibility(
editor: Option<Res<EditorState>>,
mut overlays: Query<&mut Node, With<PlayOverlay>>,
) {
let enabled = editor.as_deref().map_or(true, |state| state.enabled);
let display = if enabled {
Display::Flex
} else {
Display::None
};
for mut node in &mut overlays {
if node.display != display {
node.display = display;
}
}
}
#[cfg(test)]
mod tests {
use std::{collections::HashMap, path::PathBuf, time::Duration};
use bevy_forge_core::{ComponentData, EntityId, Mesh3dData, Project, SceneDoc};
use super::*;
fn play_app() -> App {
let mut app = App::new();
app.init_resource::<ButtonInput<KeyCode>>()
.init_resource::<Time>()
.add_plugins(PlayPlugin);
app
}
/// Runs one frame and clears `just_pressed`, matching `InputPlugin`'s
/// per-frame reset that a headless test app does not install.
fn tick(app: &mut App) {
app.update();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.clear();
}
fn scene_document(doc: SceneDoc, entities: HashMap<EntityId, Entity>) -> SceneDocument {
SceneDocument {
doc,
project: Project {
root: PathBuf::from("/tmp/forge-t21"),
name: "forge-t21-test".into(),
assets_dir: PathBuf::from("/tmp/forge-t21/assets"),
bevy_version: Some("0.20".into()),
},
scene_path: PathBuf::from("scenes/test.forge.ron"),
save_path: PathBuf::from("scenes/out.forge.ron"),
entities,
loaded: true,
}
}
/// Presses keys for the next update, clearing stale `just_pressed` flags.
fn press(app: &mut App, keys: impl IntoIterator<Item = KeyCode>) {
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.reset_all();
for key in keys {
input.press(key);
}
}
/// Triggers an overlay button's `Activate` event like a pointer click does.
fn activate<C: Component>(app: &mut App) {
let button = {
let mut query = app.world_mut().query_filtered::<Entity, With<C>>();
query
.single(app.world())
.expect("the overlay must contain this button")
};
app.world_mut()
.entity_mut(button)
.trigger(|entity| Activate { entity });
}
fn spawn_tracked<const N: usize>(
app: &mut App,
doc: &mut SceneDoc,
transforms: [Transform; N],
) -> (HashMap<EntityId, Entity>, Vec<(Entity, Transform)>) {
let mut entities = HashMap::new();
let mut originals = Vec::new();
for (index, transform) in transforms.into_iter().enumerate() {
let id = doc.add_entity(format!("Entity {index}"), None);
let entity = app.world_mut().spawn(transform).id();
entities.insert(id, entity);
originals.push((entity, transform));
}
(entities, originals)
}
#[test]
fn snapshot_and_restore_are_exact() {
let mut app = play_app();
let mut doc = SceneDoc::new("Snapshot");
let (entities, originals) = spawn_tracked(
&mut app,
&mut doc,
[
Transform::from_xyz(1.5, -2.0, 3.25),
Transform::from_xyz(-4.0, 0.5, 2.0)
.with_rotation(Quat::from_rotation_y(0.75))
.with_scale(Vec3::new(2.0, 0.5, 1.25)),
],
);
app.insert_resource(scene_document(doc, entities));
tick(&mut app);
press(&mut app, [KeyCode::KeyP]);
tick(&mut app);
let play = app.world().resource::<PlayState>();
assert_eq!(play.mode, PlayMode::Playing);
assert_eq!(play.snapshot.len(), originals.len());
// Move the live transforms far away from the snapshot while playing.
for (entity, _) in &originals {
let mut transform = app.world_mut().get_mut::<Transform>(*entity).unwrap();
transform.translation += Vec3::new(11.0, 22.0, 33.0);
transform.rotation = Quat::from_rotation_z(1.25);
transform.scale *= 3.0;
}
press(&mut app, [KeyCode::ShiftLeft, KeyCode::KeyP]);
tick(&mut app);
let play = app.world().resource::<PlayState>();
assert_eq!(play.mode, PlayMode::Editing);
assert!(play.snapshot.is_empty(), "stop must clear the snapshot");
for (entity, original) in &originals {
assert_eq!(
app.world().get::<Transform>(*entity),
Some(original),
"stop must restore the exact pre-play transform"
);
}
}
#[test]
fn transition_editing_playing_paused_playing_stop_keeps_one_snapshot() {
let mut app = play_app();
let mut doc = SceneDoc::new("Transitions");
let (entities, originals) =
spawn_tracked(&mut app, &mut doc, [Transform::from_xyz(1.0, 2.0, 3.0)]);
app.insert_resource(scene_document(doc, entities));
tick(&mut app);
assert_eq!(app.world().resource::<PlayState>().mode, PlayMode::Editing);
// Editing -> Playing snapshots exactly once.
press(&mut app, [KeyCode::KeyP]);
tick(&mut app);
let snapshot = app.world().resource::<PlayState>().snapshot.clone();
assert_eq!(app.world().resource::<PlayState>().mode, PlayMode::Playing);
assert_eq!(snapshot.len(), 1);
// Playing -> Paused keeps the snapshot.
press(&mut app, [KeyCode::KeyP]);
tick(&mut app);
assert_eq!(app.world().resource::<PlayState>().mode, PlayMode::Paused);
assert_eq!(app.world().resource::<PlayState>().snapshot, snapshot);
// Paused -> Playing keeps the same snapshot (does not re-snapshot).
press(&mut app, [KeyCode::KeyP]);
tick(&mut app);
assert_eq!(app.world().resource::<PlayState>().mode, PlayMode::Playing);
assert_eq!(app.world().resource::<PlayState>().snapshot, snapshot);
// Playing -> Editing by Escape restores and clears.
press(&mut app, [KeyCode::Escape]);
tick(&mut app);
let play = app.world().resource::<PlayState>();
assert_eq!(play.mode, PlayMode::Editing);
assert!(play.snapshot.is_empty());
assert_eq!(
app.world().get::<Transform>(originals[0].0),
Some(&originals[0].1)
);
}
#[test]
fn overlay_buttons_drive_the_same_transitions() {
let mut app = play_app();
let mut doc = SceneDoc::new("Buttons");
let (entities, originals) =
spawn_tracked(&mut app, &mut doc, [Transform::from_xyz(2.0, 0.0, -1.0)]);
app.insert_resource(scene_document(doc, entities));
tick(&mut app);
activate::<PlayButton>(&mut app);
assert_eq!(app.world().resource::<PlayState>().mode, PlayMode::Playing);
assert_eq!(app.world().resource::<PlayState>().snapshot.len(), 1);
activate::<PauseButton>(&mut app);
assert_eq!(app.world().resource::<PlayState>().mode, PlayMode::Paused);
activate::<PlayButton>(&mut app);
assert_eq!(app.world().resource::<PlayState>().mode, PlayMode::Playing);
activate::<StopButton>(&mut app);
let play = app.world().resource::<PlayState>();
assert_eq!(play.mode, PlayMode::Editing);
assert!(play.snapshot.is_empty());
assert_eq!(
app.world().get::<Transform>(originals[0].0),
Some(&originals[0].1)
);
// Pause from editing snapshots and enters pause, per contract.
activate::<PauseButton>(&mut app);
assert_eq!(app.world().resource::<PlayState>().mode, PlayMode::Paused);
assert_eq!(app.world().resource::<PlayState>().snapshot.len(), 1);
activate::<StopButton>(&mut app);
assert_eq!(app.world().resource::<PlayState>().mode, PlayMode::Editing);
}
#[test]
fn play_without_a_document_is_a_no_op() {
let mut app = play_app();
tick(&mut app);
press(&mut app, [KeyCode::KeyP]);
tick(&mut app);
let play = app.world().resource::<PlayState>();
assert_eq!(play.mode, PlayMode::Editing);
assert!(play.snapshot.is_empty());
press(&mut app, [KeyCode::ShiftLeft, KeyCode::KeyP]);
tick(&mut app);
assert_eq!(app.world().resource::<PlayState>().mode, PlayMode::Editing);
}
#[test]
fn validation_summary_counts_errors_warnings_and_infos() {
let mut app = play_app();
let mut doc = SceneDoc::new("Validation");
let twin_a = doc.add_entity("Twin", None);
let twin_b = doc.add_entity("Twin", None);
let flat = doc.add_entity("Flat", None);
let broken = doc.add_entity("Broken", None);
doc.entity_mut(flat).unwrap().transform.scale = bevy_forge_core::Vec3::new(1.0, 0.0, 1.0);
doc.entity_mut(broken).unwrap().transform.translation.x = f32::NAN;
let mut entities = HashMap::new();
for id in [twin_a, twin_b, flat, broken] {
entities.insert(id, app.world_mut().spawn(Transform::default()).id());
}
app.insert_resource(scene_document(doc, entities));
tick(&mut app);
// 2 duplicate-name warnings + 1 zero-scale warning, 1 non-finite error,
// 1 info per component-less entity + 1 "scene has no camera" info.
let summary = *app.world().resource::<ValidationSummary>();
assert_eq!(summary.errors, 1);
assert_eq!(summary.warnings, 3);
assert_eq!(summary.infos, 5);
}
#[test]
fn validation_runs_again_when_the_document_changes() {
let mut app = play_app();
let mut doc = SceneDoc::new("Revalidate");
let id = doc.add_entity("Only", None);
let mut entities = HashMap::new();
entities.insert(id, app.world_mut().spawn(Transform::default()).id());
app.insert_resource(scene_document(doc, entities));
tick(&mut app);
assert_eq!(app.world().resource::<ValidationSummary>().warnings, 0);
// Mutating the document marks the resource changed and revalidates.
{
let entity = app.world_mut().spawn(Transform::default()).id();
let mut document = app.world_mut().resource_mut::<SceneDocument>();
let duplicate = document.doc.add_entity("Only", None);
document.entities.insert(duplicate, entity);
}
tick(&mut app);
assert_eq!(app.world().resource::<ValidationSummary>().warnings, 2);
}
#[test]
fn playing_rotates_tracked_meshes_and_skips_ground_camera_and_lights() {
let mut app = play_app();
let mut doc = SceneDoc::new("Animate");
let mut entities = HashMap::new();
let spawn_mesh =
|app: &mut App, doc: &mut SceneDoc, name: &'static str| -> (EntityId, Entity) {
let id = doc.add_entity(name, None);
doc.add_component(id, ComponentData::Mesh3d(Mesh3dData::default()));
(
id,
app.world_mut()
.spawn((Name::new(name), Mesh3d::default(), Transform::default()))
.id(),
)
};
let (spinner_id, spinner) = spawn_mesh(&mut app, &mut doc, "Spinner");
entities.insert(spinner_id, spinner);
let (ground_id, ground) = spawn_mesh(&mut app, &mut doc, "Ground");
entities.insert(ground_id, ground);
let camera_id = doc.add_entity("Camera", None);
doc.add_component(camera_id, ComponentData::Mesh3d(Mesh3dData::default()));
let camera = app
.world_mut()
.spawn((
Name::new("Camera"),
Camera3d::default(),
Transform::default(),
))
.id();
entities.insert(camera_id, camera);
let sun_id = doc.add_entity("Sun", None);
let sun = app
.world_mut()
.spawn((
Name::new("Sun"),
DirectionalLight::default(),
Mesh3d::default(),
Transform::default(),
))
.id();
entities.insert(sun_id, sun);
// A mesh outside the document must never be animated.
let untracked = app
.world_mut()
.spawn((
Name::new("Untracked"),
Mesh3d::default(),
Transform::default(),
))
.id();
app.insert_resource(scene_document(doc, entities));
tick(&mut app);
activate::<PlayButton>(&mut app);
app.world_mut()
.resource_mut::<Time>()
.advance_by(Duration::from_secs_f32(1.0));
tick(&mut app);
let spinner_rotation = app.world().get::<Transform>(spinner).unwrap().rotation;
assert_ne!(
spinner_rotation,
Quat::IDENTITY,
"tracked mesh must rotate while playing"
);
assert_eq!(
app.world().get::<Transform>(ground).unwrap().rotation,
Quat::IDENTITY,
"Ground must be skipped"
);
assert_eq!(
app.world().get::<Transform>(camera).unwrap().rotation,
Quat::IDENTITY,
"cameras must be skipped"
);
assert_eq!(
app.world().get::<Transform>(sun).unwrap().rotation,
Quat::IDENTITY,
"lights must be skipped"
);
assert_eq!(
app.world().get::<Transform>(untracked).unwrap().rotation,
Quat::IDENTITY,
"entities outside the document must be skipped"
);
// Pause freezes the scene, even with a large time delta.
press(&mut app, [KeyCode::KeyP]);
tick(&mut app);
app.world_mut()
.resource_mut::<Time>()
.advance_by(Duration::from_secs_f32(1.0));
tick(&mut app);
assert_eq!(
app.world().get::<Transform>(spinner).unwrap().rotation,
spinner_rotation,
"paused scenes must not animate"
);
// Stop restores the pre-play rotation exactly.
press(&mut app, [KeyCode::ShiftLeft, KeyCode::KeyP]);
tick(&mut app);
assert_eq!(
app.world().get::<Transform>(spinner).unwrap().rotation,
Quat::IDENTITY
);
}
#[test]
fn overlay_sits_in_the_viewport_top_right_and_shows_state_and_counts() {
let mut app = play_app();
tick(&mut app);
let mut overlays = app
.world_mut()
.query_filtered::<Entity, With<PlayOverlay>>();
assert_eq!(overlays.iter(app.world()).count(), 1);
drop(overlays);
for count in [
app.world_mut()
.query_filtered::<Entity, With<PlayButton>>()
.iter(app.world())
.count(),
app.world_mut()
.query_filtered::<Entity, With<PauseButton>>()
.iter(app.world())
.count(),
app.world_mut()
.query_filtered::<Entity, With<StopButton>>()
.iter(app.world())
.count(),
] {
assert_eq!(count, 1, "overlay must contain exactly one of each button");
}
let layout = *app.world().resource::<EditorLayout>();
let overlay = app
.world_mut()
.query_filtered::<Entity, With<PlayOverlay>>()
.single(app.world())
.unwrap();
let node = app.world().get::<Node>(overlay).unwrap();
assert_eq!(node.top, px(layout.top + OVERLAY_MARGIN));
assert_eq!(node.right, px(layout.right + OVERLAY_MARGIN));
let mode_text = app
.world_mut()
.query_filtered::<&Text, With<PlayModeText>>()
.single(app.world())
.unwrap();
assert_eq!(mode_text.0, "Mode: Editing");
let validation_text = app
.world_mut()
.query_filtered::<&Text, With<ValidationText>>()
.single(app.world())
.unwrap();
assert_eq!(validation_text.0, "Errors: 0 Warnings: 0 Infos: 0");
}
}
+8 -3
View File
@@ -3,9 +3,10 @@
use bevy::prelude::*; use bevy::prelude::*;
use crate::{ use crate::{
gizmo::GizmoPlugin, hierarchy::HierarchyPlugin, history::HistoryPlugin, assets::AssetBrowserPlugin, export::ExportPlugin, gizmo::GizmoPlugin,
inspector::InspectorPlugin, scene::ScenePlugin, screenshot::ScreenshotPlugin, hierarchy::HierarchyPlugin, history::HistoryPlugin, inspector::InspectorPlugin,
shortcuts::ShortcutsPlugin, ui::shell::EditorUiPlugin, play::PlayPlugin, scene::ScenePlugin, screenshot::ScreenshotPlugin, shortcuts::ShortcutsPlugin,
ui::shell::EditorUiPlugin, watcher::WatcherPlugin,
}; };
/// Installs the Bevy Forge live editor on top of `DefaultPlugins`. /// Installs the Bevy Forge live editor on top of `DefaultPlugins`.
@@ -24,6 +25,10 @@ impl Plugin for EditorPlugin {
ScenePlugin, ScenePlugin,
ShortcutsPlugin, ShortcutsPlugin,
HistoryPlugin, HistoryPlugin,
PlayPlugin,
AssetBrowserPlugin,
ExportPlugin,
WatcherPlugin,
HierarchyPlugin, HierarchyPlugin,
InspectorPlugin, InspectorPlugin,
GizmoPlugin, GizmoPlugin,
+570
View File
@@ -0,0 +1,570 @@
//! `.forge.ron` hot reload watcher (T23, M2 Live).
//!
//! The watcher polls the mtime of the loaded scene file about twice a second.
//! When it changes, the file is parsed with [`Project::load_scene`] and the
//! supported editor fields of the entities that still exist are copied into the
//! live ECS and the document:
//!
//! - `Transform` (core XYZ Euler degrees converted back to a quaternion),
//! - `Name`,
//! - `Visibility`.
//!
//! M2 deliberately does **not** support structural changes on reload: adding or
//! removing entities or components is reported once per reload with
//! `watcher: add/remove ignored`, and only the common entity IDs are updated.
//! The watcher never spawns or despawns anything, so the scene camera and the
//! [`EditorCamera`](crate::gizmo::EditorCamera) stay alive no matter what the
//! file says. Without a loaded [`SceneDocument`] the watcher is a no-op.
//!
//! Watching the save target is out of scope: `Ctrl+S` writes
//! [`SceneDocument::save_path`], which is normally a different file from the
//! loaded source scene.
use std::{collections::BTreeSet, time::SystemTime};
use bevy::prelude::*;
use bevy_forge_core::{ComponentData, EntityId, SceneDoc, VisibilityMode};
use crate::{
plugin::{editor_active, EditorState},
scene::{transform_from_core, SceneDocument},
};
/// Seconds between two mtime polls.
pub const WATCH_INTERVAL_SECS: f32 = 0.5;
/// Poll state for the loaded scene file.
#[derive(Resource, Debug)]
pub struct SceneWatcher {
/// Mtime observed at the last poll; the first observation only establishes a
/// baseline and does not trigger a reload.
pub last_mtime: Option<SystemTime>,
/// Seconds accumulated since the last poll.
pub last_check: f32,
}
impl Default for SceneWatcher {
fn default() -> Self {
Self {
last_mtime: None,
last_check: 0.0,
}
}
}
/// Installs the scene file watcher.
pub struct WatcherPlugin;
impl Plugin for WatcherPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<SceneWatcher>()
// `EditorPlugin` installs this too; initializing it here keeps the
// plugin self-contained for standalone apps and unit tests.
.init_resource::<EditorState>()
.add_systems(Update, poll_scene_file.run_if(editor_active));
}
}
/// Classification of a freshly observed mtime.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MtimeEvent {
/// First observation: remember it, do not reload.
Baseline,
/// Same mtime as the previous poll: nothing happened.
Unchanged,
/// The file was modified since the previous poll.
Changed,
}
/// Compares the stored mtime with a freshly observed one.
pub fn mtime_event(previous: Option<SystemTime>, current: SystemTime) -> MtimeEvent {
match previous {
None => MtimeEvent::Baseline,
Some(previous) if previous == current => MtimeEvent::Unchanged,
Some(_) => MtimeEvent::Changed,
}
}
/// Whether a reloaded document has exactly the live document's entity IDs.
pub fn same_entity_ids(current: &SceneDoc, reloaded: &SceneDoc) -> bool {
let current: BTreeSet<EntityId> = current.entities.keys().copied().collect();
let reloaded: BTreeSet<EntityId> = reloaded.entities.keys().copied().collect();
current == reloaded
}
/// Polls the scene mtime every [`WATCH_INTERVAL_SECS`] and reloads on change.
fn poll_scene_file(
time: Res<Time>,
document: Option<ResMut<SceneDocument>>,
mut watcher: ResMut<SceneWatcher>,
mut names: Query<&mut Name>,
mut transforms: Query<&mut Transform>,
mut visibilities: Query<&mut Visibility>,
) {
watcher.last_check += time.delta_secs();
if watcher.last_check < WATCH_INTERVAL_SECS {
return;
}
watcher.last_check = 0.0;
let Some(mut document) = document else {
return;
};
let path = document.project.root.join(&document.scene_path);
let mtime = std::fs::metadata(&path)
.and_then(|metadata| metadata.modified())
.ok();
let Some(mtime) = mtime else {
// A missing/unreadable scene is not fatal for the live editor; the next
// poll simply tries again.
return;
};
match mtime_event(watcher.last_mtime, mtime) {
MtimeEvent::Unchanged => return,
MtimeEvent::Baseline => {
watcher.last_mtime = Some(mtime);
info!("watcher: watching {}", path.display());
return;
}
MtimeEvent::Changed => watcher.last_mtime = Some(mtime),
}
let reloaded = match document.project.load_scene(&path) {
Ok(reloaded) => reloaded,
Err(error) => {
warn!("watcher: reload failed: {error}");
return;
}
};
if !same_entity_ids(&document.doc, &reloaded) {
info!("watcher: add/remove ignored");
}
let reloaded_count = apply_reload(
&mut document,
&reloaded,
&mut names,
&mut transforms,
&mut visibilities,
);
info!("watcher: reloaded transforms ({reloaded_count} entities)");
}
/// Applies the reloaded transform/name/visibility to the live ECS and the
/// document, returning the number of entities whose live `Transform` was
/// updated.
///
/// Only document IDs that exist in both documents are touched; entities that
/// were added or removed on disk are skipped, and nothing is spawned or
/// despawned.
fn apply_reload(
document: &mut SceneDocument,
reloaded: &SceneDoc,
names: &mut Query<&mut Name>,
transforms: &mut Query<&mut Transform>,
visibilities: &mut Query<&mut Visibility>,
) -> usize {
// Snapshot the mapping first so the document can be mutated in the loop.
let mapped: Vec<(EntityId, Entity)> = document
.entities
.iter()
.map(|(id, entity)| (*id, *entity))
.collect();
let mut reloaded_count = 0;
for (id, entity) in mapped {
let Some(reloaded_node) = reloaded.entity(id) else {
continue;
};
if let Ok(mut transform) = transforms.get_mut(entity) {
*transform = transform_from_core(reloaded_node.transform);
reloaded_count += 1;
}
if let Ok(mut name) = names.get_mut(entity) {
name.set(reloaded_node.name.clone());
}
if let Some(ComponentData::Visibility(mode)) = reloaded_node.component("visibility") {
if let Ok(mut visibility) = visibilities.get_mut(entity) {
*visibility = visibility_from_core(*mode);
}
}
if let Some(node) = document.doc.entity_mut(id) {
node.transform = reloaded_node.transform;
node.name = reloaded_node.name.clone();
if let Some(ComponentData::Visibility(mode)) = reloaded_node.component("visibility") {
node.components
.retain(|component| component.key() != "visibility");
node.components.push(ComponentData::Visibility(*mode));
}
}
}
reloaded_count
}
/// Mirrors [`crate::scene`]'s core-to-Bevy visibility mapping.
fn visibility_from_core(mode: VisibilityMode) -> Visibility {
match mode {
VisibilityMode::Inherited => Visibility::Inherited,
VisibilityMode::Visible => Visibility::Visible,
VisibilityMode::Hidden => Visibility::Hidden,
}
}
#[cfg(test)]
mod tests {
use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
time::Duration,
};
use bevy::math::EulerRot;
use bevy_forge_core::{
math::Vec3 as CoreVec3, ComponentData, Project, SceneDoc, Transform as CoreTransform,
VisibilityMode,
};
use super::*;
use crate::scene::{SceneDocument, DEFAULT_SAVE_PATH};
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-t23-watcher-{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-t23-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 save(&self, doc: &SceneDoc) -> PathBuf {
self.open()
.save_scene(doc, Some(Path::new("scenes/test.forge.ron")))
.expect("scene must save")
}
}
impl Drop for TempProject {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
fn scene_document(project: Project, doc: SceneDoc) -> SceneDocument {
SceneDocument {
doc,
project,
scene_path: PathBuf::from("scenes/test.forge.ron"),
save_path: PathBuf::from(DEFAULT_SAVE_PATH),
entities: HashMap::new(),
loaded: true,
}
}
fn watcher_app(document: Option<SceneDocument>) -> App {
let mut app = App::new();
app.init_resource::<Time>();
if let Some(document) = document {
app.insert_resource(document);
}
app.add_plugins(WatcherPlugin);
app
}
/// Advances Bevy's clock and runs one frame, so the poll interval elapses.
fn tick_after(app: &mut App, seconds: f32) {
app.world_mut()
.resource_mut::<Time>()
.advance_by(Duration::from_secs_f32(seconds));
app.update();
}
/// Forces the next poll to observe a change regardless of filesystem mtime
/// granularity.
fn force_change(app: &mut App) {
app.world_mut().resource_mut::<SceneWatcher>().last_mtime = Some(SystemTime::UNIX_EPOCH);
}
fn core_transform(translation: [f32; 3], rotation: [f32; 3], scale: [f32; 3]) -> CoreTransform {
CoreTransform {
translation: CoreVec3::new(translation[0], translation[1], translation[2]),
rotation: CoreVec3::new(rotation[0], rotation[1], rotation[2]),
scale: CoreVec3::new(scale[0], scale[1], scale[2]),
}
}
#[test]
fn mtime_events_detect_changes_and_baseline_the_first_poll() {
let first = SystemTime::UNIX_EPOCH + Duration::from_millis(1_000);
assert_eq!(mtime_event(None, first), MtimeEvent::Baseline);
assert_eq!(mtime_event(Some(first), first), MtimeEvent::Unchanged);
assert_eq!(
mtime_event(Some(first), first + Duration::from_millis(1)),
MtimeEvent::Changed
);
}
#[test]
fn reload_applies_transform_name_and_visibility() {
let temp = TempProject::new("apply");
let project = temp.open();
let mut doc = SceneDoc::new("Watcher Apply");
let root = doc.add_entity("Root", None);
doc.add_component(root, ComponentData::Visibility(VisibilityMode::Hidden));
temp.save(&doc);
let loaded = project
.load_scene(temp.root.join("scenes/test.forge.ron"))
.expect("scene must load");
let mut app = watcher_app(Some(scene_document(project.clone(), loaded)));
let entity = {
let world = app.world_mut();
world
.spawn((
Name::new("Root"),
Transform::default(),
Visibility::Inherited,
))
.id()
};
app.world_mut()
.resource_mut::<SceneDocument>()
.entities
.insert(root, entity);
// First poll only records the baseline.
tick_after(&mut app, 0.6);
assert!(app.world().resource::<SceneWatcher>().last_mtime.is_some());
assert_eq!(
app.world().get::<Name>(entity).unwrap().as_str(),
"Root",
"baseline poll must not reload"
);
let transform = core_transform([2.0, 3.5, -4.0], [10.0, 20.0, -30.0], [2.0, 1.0, 0.5]);
let mut changed = doc.clone();
let node = changed.entity_mut(root).unwrap();
node.name = "Root Renamed".into();
node.transform = transform;
changed.set_component(root, ComponentData::Visibility(VisibilityMode::Visible));
temp.save(&changed);
force_change(&mut app);
tick_after(&mut app, 0.6);
let live = *app.world().get::<Transform>(entity).unwrap();
assert!(live
.translation
.abs_diff_eq(Vec3::new(2.0, 3.5, -4.0), 1e-5));
let expected_rotation = Quat::from_euler(
EulerRot::XYZ,
10.0f32.to_radians(),
20.0f32.to_radians(),
(-30.0f32).to_radians(),
);
assert!(
live.rotation.abs_diff_eq(expected_rotation, 1e-5),
"expected Euler degrees converted back to a quaternion, got {:?}",
live.rotation
);
assert!(live.scale.abs_diff_eq(Vec3::new(2.0, 1.0, 0.5), 1e-5));
assert_eq!(
app.world().get::<Name>(entity).unwrap().as_str(),
"Root Renamed"
);
assert_eq!(
app.world().get::<Visibility>(entity),
Some(&Visibility::Visible)
);
let document = app.world().resource::<SceneDocument>();
let node = document.doc.entity(root).unwrap();
assert_eq!(node.name, "Root Renamed");
assert_eq!(node.transform, transform);
assert_eq!(node.visibility(), VisibilityMode::Visible);
assert_eq!(
app.world().resource::<SceneWatcher>().last_mtime,
std::fs::metadata(temp.root.join("scenes/test.forge.ron"))
.unwrap()
.modified()
.ok()
);
}
#[test]
fn entity_id_mismatch_applies_common_ids_and_never_spawns_or_despawns() {
let temp = TempProject::new("mismatch");
let project = temp.open();
let mut doc = SceneDoc::new("Watcher Mismatch");
let camera_id = doc.add_entity("Camera", None);
doc.add_component(camera_id, ComponentData::Camera3d);
let box_id = doc.add_entity("Box", None);
temp.save(&doc);
let loaded = project
.load_scene(temp.root.join("scenes/test.forge.ron"))
.expect("scene must load");
assert!(same_entity_ids(&loaded, &doc));
let mut app = watcher_app(Some(scene_document(project.clone(), loaded)));
let (camera_entity, box_entity) = {
let world = app.world_mut();
let camera = world
.spawn((
Name::new("Camera"),
Transform::default(),
Camera3d::default(),
))
.id();
let entity = world.spawn((Name::new("Box"), Transform::default())).id();
(camera, entity)
};
{
let mut document = app.world_mut().resource_mut::<SceneDocument>();
document.entities.insert(camera_id, camera_entity);
document.entities.insert(box_id, box_entity);
}
tick_after(&mut app, 0.6);
// Add a new ID and remove an existing one on disk, and move the common
// camera entity. Structural changes must be ignored.
let mut changed = doc.clone();
changed.remove_entity(box_id);
let added = changed.add_entity("Added On Disk", None);
changed.entity_mut(camera_id).unwrap().transform =
core_transform([7.0, 8.0, 9.0], [0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
temp.save(&changed);
assert!(!same_entity_ids(&doc, &changed));
force_change(&mut app);
tick_after(&mut app, 0.6);
{
let world = app.world_mut();
assert_eq!(
world
.query_filtered::<Entity, With<Camera3d>>()
.iter(world)
.count(),
1,
"reload must never spawn a second Camera3d"
);
assert_eq!(
world
.query_filtered::<Entity, With<Name>>()
.iter(world)
.count(),
2,
"an entity added on disk must not be spawned in M2"
);
}
let world = app.world();
assert_eq!(
world.get::<Transform>(camera_entity).unwrap().translation,
Vec3::new(7.0, 8.0, 9.0),
"common IDs must still receive their transforms"
);
assert!(
world.get::<Name>(box_entity).is_some(),
"an entity removed on disk must not be despawned"
);
assert!(
world.get::<Camera3d>(camera_entity).is_some(),
"the camera must stay alive"
);
let document = world.resource::<SceneDocument>();
assert_eq!(
document.doc.entities.len(),
2,
"the document keeps its live ID set"
);
assert!(document.doc.entity(camera_id).is_some());
assert!(document.doc.entity(box_id).is_some());
assert!(
document.doc.entity(added).is_none(),
"new document IDs are not adopted in M2"
);
assert_eq!(
document
.doc
.entity(camera_id)
.unwrap()
.transform
.translation,
CoreVec3::new(7.0, 8.0, 9.0)
);
}
#[test]
fn unchanged_mtime_does_not_reload() {
let temp = TempProject::new("unchanged");
let project = temp.open();
let mut doc = SceneDoc::new("Watcher Unchanged");
let id = doc.add_entity("Stable", None);
temp.save(&doc);
let loaded = project
.load_scene(temp.root.join("scenes/test.forge.ron"))
.expect("scene must load");
let mut app = watcher_app(Some(scene_document(project, loaded)));
let entity = app
.world_mut()
.spawn((Name::new("Stable"), Transform::default()))
.id();
app.world_mut()
.resource_mut::<SceneDocument>()
.entities
.insert(id, entity);
tick_after(&mut app, 0.6);
// Move the live entity; an unchanged file must leave it alone.
app.world_mut()
.get_mut::<Transform>(entity)
.unwrap()
.translation = Vec3::new(9.0, 9.0, 9.0);
tick_after(&mut app, 0.6);
assert_eq!(
app.world().get::<Transform>(entity).unwrap().translation,
Vec3::new(9.0, 9.0, 9.0),
"an unchanged mtime must not reload the scene"
);
}
#[test]
fn watcher_without_a_document_is_a_no_op() {
let mut app = watcher_app(None);
tick_after(&mut app, 0.6);
tick_after(&mut app, 0.6);
let watcher = app.world().resource::<SceneWatcher>();
assert!(watcher.last_mtime.is_none());
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

+72
View File
@@ -0,0 +1,72 @@
# Bevy Forge v2 — M2 Live
Status: **M2 Live complete and independently verified (`pass_with_risks`)**. M1 Core is committed at
`3eea9c0`; M2 adds live-mode controls and project workflow around the M1 scene editor.
![Bevy Forge M2 Live — play mode and asset browser](m2-live-1440x900.png)
_1000×640 variant (play overlay, panel closed): [m2-live-1000x640.png](m2-live-1000x640.png)._
## What M2 adds
- **Play / Pause / Stop**: a viewport overlay and keyboard controls. Play animates the scene, Pause
freezes it, Stop restores every tracked transform exactly (the snapshot includes the camera pose
captured on Play). A small validation summary (errors/warnings/info) is shown for the loaded
scene.
- **Asset browser**: toggle with `B`; lists the project `assets/` folder with kind filters and an
image preview.
- **Export**: `Ctrl+E` writes BSN, `Ctrl+Shift+E` writes imperative Rust to `exports/` using the
existing `bevy_forge_core` codegen. The garden has seven roots, so its BSN uses core's
`bsn_list!`; single-root scenes use `bsn!`.
- **Hot reload watcher**: when the loaded `.forge.ron` changes on disk, transform/name/visibility
changes are applied to existing entities. Add/remove is logged and ignored in M2; the camera is
never duplicated.
## 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
```
## Controls
| Input | Action |
|---|---|
| F1 / toolbar **Editor (F1)** | Toggle the editor UI |
| Click hierarchy row | Select entity |
| Drag inspector slider / gizmo axis | Move selected entity |
| Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y | Undo / redo transform |
| Ctrl+S | Save `.forge.ron` |
| F | Focus camera |
| Delete / Ctrl+D | Delete / duplicate subtree |
| **P** | Play / pause |
| **Shift+P / Esc** | Stop and restore |
| **B** | Toggle asset browser |
| **Ctrl+E / Ctrl+Shift+E** | Export BSN / Rust |
## Limits (M2)
- Asset **drag-into-scene is deferred to M3**: `bevy_forge_core::MaterialData` has no texture field
yet, so the browser is list/filter/preview only.
- Watcher hot reload applies transform/name/visibility to existing mapped IDs; adding or removing
entities/components requires a restart in M2. The watcher has no spawn/despawn path, so the editor
camera cannot be duplicated.
- Play mode is a snapshot/restore demonstration (rotating meshes), not a full game play mode.
- 3D only; custom components are preserved in the document but skipped in the live ECS.
- Delete/duplicate are not undoable; multi-select/docking/terminal are out of scope.
- The asset browser uses the project's `assets/` via `BEVY_ASSET_ROOT` (set by `editor_live`);
files outside the asset root fall back to a local decode for preview only.
- Tests: **95 runtime tests**, workspace **441 passed / 2 ignored**. M2 validation was run with the
garden demo (9 entities / 7 roots) and computer-use for Play/Pause/Stop, the asset panel and
exports.
## Verification
M0: [`m0-spike.md`](m0-spike.md). M1: [`m1-core.md`](m1-core.md). M2: **V21 pass_with_risks**
(`.swarm/m2/reviews/V21.md`); runtime **95 tests**, workspace **441 passed / 2 ignored**. V21's
low-severity residuals are the live 3-key `Ctrl+Shift+E` portal injection (handler unit-tested), play
shortcuts remaining active while the editor is hidden, the camera pose restored on Stop by design,
and the watcher not retrying a failed parse until the next mtime change.