Merge pull request 'V2 live editor' (#1) from v2-live-editor into main

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-10-27 21:03:21 +00:00
31 changed files with 12100 additions and 64 deletions
Generated
+2924 -64
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -3,6 +3,7 @@ members = [
"crates/bevy_forge_core", "crates/bevy_forge_core",
"crates/bevy_forge_editor", "crates/bevy_forge_editor",
"crates/bevy_forge_cli", "crates/bevy_forge_cli",
"crates/bevy_forge_runtime",
] ]
exclude = ["compat/bsn-smoke"] exclude = ["compat/bsn-smoke"]
resolver = "2" resolver = "2"
+41
View File
@@ -24,6 +24,47 @@ the output easy to review, diff and check in.
Live in-game editing (a Bevy plugin with a remote protocol) is a roadmap item, not part of this Live in-game editing (a Bevy plugin with a remote protocol) is a roadmap item, not part of this
editor. editor.
## v2 live editor (M0 spike)
The `v2-live-editor` branch moves editing into the game as a **Bevy 0.20 plugin** with native Bevy
UI and official `bevy_ui_widgets` — no egui in the runtime crate. M0 ships the `EditorPlugin`, a
fixed editor shell, live hierarchy, reflection inspector, translate gizmo, editor camera and
deterministic screenshots at 1440×900 / 1000×640.
```bash
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
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.
### 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 |
+46
View File
@@ -0,0 +1,46 @@
[package]
name = "bevy_forge_runtime"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
publish = false
description = "Live in-game scene editor plugin for Bevy 0.20 (M0/M1)"
[dependencies]
bevy_forge_core = { path = "../bevy_forge_core" }
# Frozen M0 feature set. Do not add `tonemapping_luts` (ktx2/zstd) — cameras use
# `Tonemapping::AcesFitted` instead of the LUT-based default.
bevy = { version = "0.20", default-features = false, features = [
"std",
"async_executor",
"multi_threaded",
"bevy_asset",
"bevy_log",
"bevy_color",
"bevy_image",
"bevy_camera",
"bevy_light",
"bevy_mesh",
"bevy_pbr",
"bevy_render",
"bevy_core_pipeline",
"bevy_ui",
"bevy_ui_render",
"bevy_ui_widgets",
"bevy_text",
"bevy_input_focus",
"bevy_window",
"bevy_winit",
"bevy_picking",
"ui_picking",
"bevy_gizmos",
"bevy_gizmos_render",
"default_font",
"png",
"x11",
"wayland",
"reflect_auto_register",
] }
@@ -0,0 +1,41 @@
//! 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 std::path::PathBuf;
use bevy::{prelude::*, window::WindowResolution};
use bevy_forge_runtime::EditorPlugin;
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()
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "Bevy Forge — M2 Live".into(),
resolution: WindowResolution::new(1440, 900).with_scale_factor_override(1.0),
..default()
}),
..default()
}))
.add_plugins(EditorPlugin)
.run();
}
@@ -0,0 +1,155 @@
//! M0 spike demo: a small hardcoded 3D scene plus the Bevy Forge editor plugin.
//!
//! Run:
//! cargo run -p bevy_forge_runtime --example editor_spike
//!
//! Deterministic screenshot:
//! FORGE_SCREENSHOT=$PWD/.swarm/m0/evidence/m0-1440x900.png \
//! FORGE_SCREENSHOT_SIZE=1440x900 \
//! cargo run -p bevy_forge_runtime --example editor_spike
use bevy::{prelude::*, render::view::Tonemapping, window::WindowResolution};
use bevy_forge_runtime::{EditorPlugin, Selection};
fn main() {
App::new()
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "Bevy Forge — M0 Spike".into(),
resolution: WindowResolution::new(1440, 900).with_scale_factor_override(1.0),
..default()
}),
..default()
}))
.add_plugins(EditorPlugin)
.add_systems(Startup, demo_scene)
.run();
}
fn demo_scene(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
mut selection: ResMut<Selection>,
) {
commands.insert_resource(GlobalAmbientLight {
color: Color::WHITE,
brightness: 600.0,
..default()
});
let ground = materials.add(StandardMaterial {
base_color: Color::srgb(0.16, 0.20, 0.17),
perceptual_roughness: 0.95,
metallic: 0.0,
..default()
});
commands.spawn((
Name::new("Ground"),
Mesh3d(meshes.add(Plane3d::default().mesh().size(14.0, 14.0))),
MeshMaterial3d(ground),
Transform::from_xyz(0.0, -0.02, 0.0),
));
let hero_mat = materials.add(StandardMaterial {
base_color: Color::srgb(0.85, 0.55, 0.25),
perceptual_roughness: 0.65,
metallic: 0.0,
..default()
});
let hat_mat = materials.add(StandardMaterial {
base_color: Color::srgb(0.25, 0.45, 0.75),
perceptual_roughness: 0.5,
metallic: 0.0,
..default()
});
let hero = commands
.spawn((
Name::new("Hero Cube"),
Mesh3d(meshes.add(Cuboid::new(1.0, 1.0, 1.0))),
MeshMaterial3d(hero_mat),
Transform::from_xyz(0.0, 0.5, 0.0),
))
.with_children(|parent| {
parent.spawn((
Name::new("Hat"),
Mesh3d(meshes.add(Cuboid::new(0.55, 0.18, 0.55))),
MeshMaterial3d(hat_mat),
Transform::from_xyz(0.0, 0.62, 0.0),
));
})
.id();
let red_mat = materials.add(StandardMaterial {
base_color: Color::srgb(0.75, 0.18, 0.16),
perceptual_roughness: 0.7,
..default()
});
let blue_mat = materials.add(StandardMaterial {
base_color: Color::srgb(0.18, 0.35, 0.78),
perceptual_roughness: 0.7,
..default()
});
commands.spawn((
Name::new("Red Cube"),
Mesh3d(meshes.add(Cuboid::new(0.8, 0.8, 0.8))),
MeshMaterial3d(red_mat),
Transform::from_xyz(-2.6, 0.4, 0.6),
));
commands.spawn((
Name::new("Blue Cube"),
Mesh3d(meshes.add(Cuboid::new(0.8, 0.8, 0.8))),
MeshMaterial3d(blue_mat),
Transform::from_xyz(2.6, 0.4, -0.6),
));
let crate_mat = materials.add(StandardMaterial {
base_color: Color::srgb(0.55, 0.38, 0.20),
perceptual_roughness: 0.9,
..default()
});
let stack = commands
.spawn((
Name::new("Crate Stack"),
Transform::from_xyz(0.0, 0.0, -2.8),
Visibility::default(),
))
.with_children(|parent| {
parent.spawn((
Name::new("Crate A"),
Mesh3d(meshes.add(Cuboid::new(0.8, 0.8, 0.8))),
MeshMaterial3d(crate_mat.clone()),
Transform::from_xyz(0.0, 0.4, 0.0),
));
parent.spawn((
Name::new("Crate B"),
Mesh3d(meshes.add(Cuboid::new(0.8, 0.8, 0.8))),
MeshMaterial3d(crate_mat),
Transform::from_xyz(0.0, 1.2, 0.0),
));
})
.id();
commands.spawn((
Name::new("Sun"),
DirectionalLight {
illuminance: 12_000.0,
..default()
},
Transform::from_xyz(4.0, 8.0, 4.0).looking_at(Vec3::ZERO, Vec3::Y),
));
// Placeholder camera. T04 adopts it for the editor orbit/pan/zoom controls.
commands.spawn((
Name::new("Editor Camera"),
Camera3d::default(),
Tonemapping::AcesFitted,
Transform::from_xyz(6.0, 5.0, 10.0).looking_at(Vec3::new(0.0, 0.5, 0.0), Vec3::Y),
));
selection.0 = Some(hero);
info!(
"demo scene ready: hero={hero:?}, stack={stack:?}, selected={:?}",
selection.0
);
}
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"
);
}
}
+799
View File
@@ -0,0 +1,799 @@
//! Translate gizmo and editor camera.
//!
//! M0 scope:
//! - adopt the host app's placeholder `Camera3d` (or spawn one) and drive it with
//! right-drag orbit, middle-drag pan and wheel zoom;
//! - draw world-aligned RGB axes on the selected entity's `GlobalTransform`;
//! - pick an axis in screen space, then drag it with a ray/plane calculation and
//! write the result back to the selected entity's `Transform.translation`.
//!
//! M0 limit: the world-space drag delta is converted into parent-local space
//! with the parent's *inverse rotation* only. Parent scale is ignored, so a
//! scaled parent makes the gizmo travel at the wrong world-space rate. Generic
//! component editing, local-space gizmos and snapping are M1+.
use bevy::{
input::mouse::{MouseMotion, MouseScrollUnit, MouseWheel},
prelude::*,
render::view::Tonemapping,
window::PrimaryWindow,
};
use crate::{
history::{History, TransformEdit},
plugin::{editor_active, EditorState, Selection},
ui::EditorLayout,
};
/// Screen-space distance (logical pixels) within which a click hits a gizmo axis.
pub const AXIS_PICK_RADIUS_PX: f32 = 12.0;
const GIZMO_LENGTH_PER_DISTANCE: f32 = 0.18;
const GIZMO_MIN_LENGTH: f32 = 0.8;
const GIZMO_MAX_LENGTH: f32 = 3.0;
const MIN_CAMERA_DISTANCE: f32 = 2.0;
const MAX_CAMERA_DISTANCE: f32 = 40.0;
const DEFAULT_CAMERA_DISTANCE: f32 = 12.5;
const MAX_CAMERA_PITCH: f32 = 85.0 * std::f32::consts::PI / 180.0;
const ORBIT_SENSITIVITY: f32 = 0.005;
const PAN_SENSITIVITY: f32 = 0.0015;
const ZOOM_STEP: f32 = 0.9;
const RAY_EPSILON: f32 = 1e-6;
/// Orbit/pan/zoom state of the one editor camera.
///
/// The camera position is derived from `focus`, `yaw`, `pitch` and `distance`
/// instead of being stored directly so the controls stay a simple orbit model.
#[derive(Component, Debug, Clone, Copy, PartialEq)]
pub struct EditorCamera {
/// World-space point the camera orbits around and looks at.
pub focus: Vec3,
/// Rotation around `Vec3::Y`, radians.
pub yaw: f32,
/// Elevation above the focus plane, radians, clamped to ±85°.
pub pitch: f32,
/// Distance from `focus` to the camera, clamped to `2.0..=40.0`.
pub distance: f32,
}
impl Default for EditorCamera {
/// The M0 placeholder pose from the demo scene: eye `(6, 5, 10)`, looking
/// at `(0, 0.5, 0)`.
fn default() -> Self {
let placeholder =
Transform::from_xyz(6.0, 5.0, 10.0).looking_at(Vec3::new(0.0, 0.5, 0.0), Vec3::Y);
editor_camera_from_transform(&placeholder)
}
}
/// One of the three translate axes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GizmoAxis {
/// World +X.
X,
/// World +Y.
Y,
/// World +Z.
Z,
}
impl GizmoAxis {
/// All axes in RGB order.
pub const ALL: [Self; 3] = [Self::X, Self::Y, Self::Z];
/// Unit world-space direction of this axis.
pub fn direction(self) -> Vec3 {
match self {
Self::X => Vec3::X,
Self::Y => Vec3::Y,
Self::Z => Vec3::Z,
}
}
}
/// Active translate-gizmo drag. Reset to `Default` when no drag is running.
#[derive(Resource, Default, Debug, Clone)]
pub struct GizmoDrag {
/// Whether a drag is currently in progress.
pub active: bool,
/// The entity being moved.
pub entity: Option<Entity>,
/// The axis being dragged.
pub axis: Option<GizmoAxis>,
/// `Transform.translation` of the entity when the drag began (parent-local).
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.
pub start_world_hit: Vec3,
/// World-space gizmo origin when the drag began.
pub origin: Vec3,
/// Unit world-space direction of the dragged axis.
pub axis_world: Vec3,
/// Normal of the drag plane (perpendicular to the axis).
pub plane_normal: Vec3,
}
impl GizmoDrag {
/// Ends the current drag and clears all stored state.
pub fn clear(&mut self) {
*self = Self::default();
}
}
/// Adds the editor camera controls and the translate gizmo.
pub struct GizmoPlugin;
impl Plugin for GizmoPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<GizmoDrag>()
// `PostStartup` deliberately runs after the host app's `Startup` scene
// so the placeholder camera exists and can be adopted.
.add_systems(PostStartup, ensure_editor_camera)
.add_systems(
Update,
(
editor_camera_control,
log_selection,
begin_gizmo_drag,
update_gizmo_drag,
draw_gizmo,
)
.chain()
.run_if(editor_active),
)
// Always clean up, even if the editor was disabled mid-drag.
.add_systems(Update, end_gizmo_drag);
}
}
/// Adopts the host app's placeholder `Camera3d`, or spawns one if there is none.
///
/// Adoption never touches the existing `Transform`, so the initial framing is
/// preserved; only the orbit state is derived from it. A spawned camera uses the
/// M0 placeholder pose and `Tonemapping::AcesFitted` (no LUT assets).
fn ensure_editor_camera(
mut commands: Commands,
existing: Query<(), With<EditorCamera>>,
cameras: Query<(Entity, &Transform), With<Camera3d>>,
) {
if !existing.is_empty() {
info!("gizmo: camera ready");
return;
}
if let Some((entity, transform)) = cameras.iter().next() {
commands
.entity(entity)
.insert(editor_camera_from_transform(transform));
debug!("gizmo: adopted camera {entity}");
info!("gizmo: camera ready");
return;
}
let editor = EditorCamera::default();
let transform = camera_transform(&editor);
commands.spawn((
Name::new("Editor Camera"),
Camera3d::default(),
Tonemapping::AcesFitted,
transform,
GlobalTransform::from(transform),
editor,
));
debug!("gizmo: spawned camera");
info!("gizmo: camera ready");
}
/// Derives orbit state from a camera transform by looking along its forward ray.
///
/// The distance is estimated as the camera's distance from the world origin,
/// which keeps the placeholder framing stable while giving the orbit controls a
/// sensible pivot. Returns the default distance for a camera at the origin.
pub fn editor_camera_from_transform(transform: &Transform) -> EditorCamera {
let position = transform.translation;
let mut distance = position.length();
if !distance.is_finite() || distance < 0.01 {
distance = DEFAULT_CAMERA_DISTANCE;
}
let focus = position + *transform.forward() * distance;
let offset = position - focus;
let horizontal = Vec2::new(offset.x, offset.z).length();
let yaw = offset.x.atan2(offset.z);
let pitch = offset
.y
.atan2(horizontal)
.clamp(-MAX_CAMERA_PITCH, MAX_CAMERA_PITCH);
EditorCamera {
focus,
yaw,
pitch,
distance,
}
}
/// Builds a camera transform from orbit state. `looking_at` keeps roll at zero.
pub fn camera_transform(camera: &EditorCamera) -> Transform {
let (sin_yaw, cos_yaw) = camera.yaw.sin_cos();
let (sin_pitch, cos_pitch) = camera.pitch.sin_cos();
let offset = Vec3::new(cos_pitch * sin_yaw, sin_pitch, cos_pitch * cos_yaw) * camera.distance;
Transform::from_translation(camera.focus + offset).looking_at(camera.focus, Vec3::Y)
}
/// Gizmo axis length for a camera distance: roughly screen-constant, clamped.
pub fn gizmo_length(camera_distance: f32) -> f32 {
(camera_distance * GIZMO_LENGTH_PER_DISTANCE).clamp(GIZMO_MIN_LENGTH, GIZMO_MAX_LENGTH)
}
/// Right-drag orbit, middle-drag pan, wheel zoom while the cursor is in the viewport.
fn editor_camera_control(
windows: Query<&Window, With<PrimaryWindow>>,
layout: Res<EditorLayout>,
mouse: Res<ButtonInput<MouseButton>>,
mut motion: MessageReader<MouseMotion>,
mut wheel: MessageReader<MouseWheel>,
mut cameras: Query<(&mut EditorCamera, &mut Transform)>,
) {
// Consume input every frame so a dragged-then-released button does not leave
// stale deltas queued for the next drag.
let motion_delta = motion
.read()
.fold(Vec2::ZERO, |total, event| total + event.delta);
let wheel_delta: f32 = wheel
.read()
.map(|event| match event.unit {
MouseScrollUnit::Line => event.y,
MouseScrollUnit::Pixel => event.y / 100.0,
})
.sum();
let Ok(window) = windows.single() else {
return;
};
let in_viewport = window
.cursor_position()
.is_some_and(|cursor| layout.viewport_rect(window).contains(cursor));
if !in_viewport {
return;
}
let Ok((mut camera, mut transform)) = cameras.single_mut() else {
return;
};
let mut changed = false;
if mouse.pressed(MouseButton::Right) && motion_delta != Vec2::ZERO {
camera.yaw -= motion_delta.x * ORBIT_SENSITIVITY;
camera.pitch = (camera.pitch + motion_delta.y * ORBIT_SENSITIVITY)
.clamp(-MAX_CAMERA_PITCH, MAX_CAMERA_PITCH);
changed = true;
} else if mouse.pressed(MouseButton::Middle) && motion_delta != Vec2::ZERO {
let right = *transform.right();
let up = *transform.up();
let scale = camera.distance * PAN_SENSITIVITY;
camera.focus += (-right * motion_delta.x + up * motion_delta.y) * scale;
changed = true;
}
if wheel_delta != 0.0 {
camera.distance = (camera.distance * ZOOM_STEP.powf(wheel_delta))
.clamp(MIN_CAMERA_DISTANCE, MAX_CAMERA_DISTANCE);
changed = true;
}
if changed {
*transform = camera_transform(&camera);
}
}
/// Logs `gizmo: selected {entity}` once per selection, not once per frame.
fn log_selection(selection: Res<Selection>, mut last_logged: Local<Option<Entity>>) {
if *last_logged == selection.0 {
return;
}
*last_logged = selection.0;
if let Some(entity) = selection.0 {
info!("gizmo: selected {entity}");
}
}
/// Draws the world-aligned axes at the selected entity's global position.
fn draw_gizmo(
mut gizmos: Gizmos,
selection: Res<Selection>,
entities: Query<&GlobalTransform>,
cameras: Query<&EditorCamera>,
) {
let Some(entity) = selection.0 else {
return;
};
let Ok(global) = entities.get(entity) else {
return;
};
let distance = cameras
.iter()
.next()
.map_or(DEFAULT_CAMERA_DISTANCE, |camera| camera.distance);
let length = gizmo_length(distance);
gizmos.axes(Transform::from_translation(global.translation()), length);
}
/// Picks the nearest axis on left press inside the viewport and starts a drag.
fn begin_gizmo_drag(
mouse: Res<ButtonInput<MouseButton>>,
windows: Query<&Window, With<PrimaryWindow>>,
layout: Res<EditorLayout>,
selection: Res<Selection>,
targets: Query<(&Transform, &GlobalTransform)>,
cameras: Query<(&Camera, &GlobalTransform, &EditorCamera)>,
mut drag: ResMut<GizmoDrag>,
) {
if !mouse.just_pressed(MouseButton::Left) {
return;
}
let Ok(window) = windows.single() else {
return;
};
let Some(cursor) = window.cursor_position() else {
return;
};
if !layout.viewport_rect(window).contains(cursor) {
return;
}
let Some(entity) = selection.0 else {
return;
};
let Ok((target_transform, target_global)) = targets.get(entity) else {
return;
};
let Ok((camera, camera_global, editor_camera)) = cameras.single() else {
return;
};
let origin = target_global.translation();
let length = gizmo_length(editor_camera.distance);
let Ok(origin_screen) = camera.world_to_viewport(camera_global, origin) else {
return;
};
let mut endpoints = [(GizmoAxis::X, Vec2::ZERO); 3];
let mut endpoint_count = 0;
for axis in GizmoAxis::ALL {
let world_end = origin + axis.direction() * length;
if let Ok(screen_end) = camera.world_to_viewport(camera_global, world_end) {
endpoints[endpoint_count] = (axis, screen_end);
endpoint_count += 1;
}
}
let Some(axis) = pick_axis(
cursor,
origin_screen,
&endpoints[..endpoint_count],
AXIS_PICK_RADIUS_PX,
) else {
return;
};
let axis_world = axis.direction();
let normal = axis_plane_normal(axis_world, *camera_global.forward());
let Ok(ray) = camera.viewport_to_world(camera_global, cursor) else {
return;
};
let Some(start_world_hit) = ray_plane_hit(ray.origin, *ray.direction, origin, normal) else {
return;
};
*drag = GizmoDrag {
active: true,
entity: Some(entity),
axis: Some(axis),
start_translation: target_transform.translation,
start_transform: Some(*target_transform),
start_world_hit,
origin,
axis_world,
plane_normal: normal,
};
}
/// Updates the dragged translation while the left button stays held.
fn update_gizmo_drag(
mouse: Res<ButtonInput<MouseButton>>,
windows: Query<&Window, With<PrimaryWindow>>,
layout: Res<EditorLayout>,
cameras: Query<(&Camera, &GlobalTransform)>,
parents: Query<&ChildOf>,
globals: Query<&GlobalTransform>,
mut transforms: Query<&mut Transform>,
mut drag: ResMut<GizmoDrag>,
) {
if !drag.active {
return;
}
if !mouse.pressed(MouseButton::Left) {
// 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;
}
let (Some(entity), Some(_axis)) = (drag.entity, drag.axis) else {
drag.clear();
return;
};
let Ok(window) = windows.single() else {
return;
};
let Some(cursor) = window.cursor_position() else {
return;
};
if !layout.viewport_rect(window).contains(cursor) {
return;
}
let Ok((camera, camera_global)) = cameras.single() else {
return;
};
let Ok(ray) = camera.viewport_to_world(camera_global, cursor) else {
return;
};
let Some(hit) = ray_plane_hit(ray.origin, *ray.direction, drag.origin, drag.plane_normal)
else {
return;
};
let parent_rotation = parent_rotation_of(entity, &parents, &globals);
let translation = dragged_translation(
drag.start_translation,
drag.start_world_hit,
hit,
drag.axis_world,
parent_rotation,
);
let Ok(mut transform) = transforms.get_mut(entity) else {
drag.clear();
return;
};
transform.translation = translation;
}
/// Builds the single history edit for a finished gizmo drag.
///
/// The translate gizmo only edits `translation`, so a press/release without
/// 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.
fn parent_rotation_of(
entity: Entity,
parents: &Query<&ChildOf>,
globals: &Query<&GlobalTransform>,
) -> Quat {
parents
.get(entity)
.ok()
.and_then(|child_of| globals.get(child_of.parent()).ok())
.map(GlobalTransform::rotation)
.unwrap_or(Quat::IDENTITY)
}
/// Screen-space distance from `point` to the segment `start..end`.
pub fn point_segment_distance(point: Vec2, start: Vec2, end: Vec2) -> f32 {
let segment = end - start;
let length_squared = segment.length_squared();
if length_squared <= f32::EPSILON {
return point.distance(start);
}
let t = ((point - start).dot(segment) / length_squared).clamp(0.0, 1.0);
point.distance(start + segment * t)
}
/// Picks the nearest axis segment within `max_distance_px`, independent of order.
pub fn pick_axis(
cursor: Vec2,
origin: Vec2,
endpoints: &[(GizmoAxis, Vec2)],
max_distance_px: f32,
) -> Option<GizmoAxis> {
let mut best: Option<(GizmoAxis, f32)> = None;
for &(axis, endpoint) in endpoints {
let distance = point_segment_distance(cursor, origin, endpoint);
if distance > max_distance_px {
continue;
}
if best.is_none_or(|(_, best_distance)| distance < best_distance) {
best = Some((axis, distance));
}
}
best.map(|(axis, _)| axis)
}
/// Returns the drag-plane normal for an axis: camera forward projected onto the
/// plane perpendicular to the axis. Falls back to any perpendicular vector when
/// the view direction and the axis are (nearly) parallel.
pub fn axis_plane_normal(axis: Vec3, camera_forward: Vec3) -> Vec3 {
let axis = axis.normalize_or_zero();
let projected = camera_forward - axis * camera_forward.dot(axis);
if projected.length_squared() > RAY_EPSILON * RAY_EPSILON {
projected.normalize()
} else {
axis.any_orthonormal_vector()
}
}
/// Intersects a ray with a plane. Returns `None` for parallel rays and hits
/// behind the ray origin.
pub fn ray_plane_hit(
ray_origin: Vec3,
ray_direction: Vec3,
plane_origin: Vec3,
plane_normal: Vec3,
) -> Option<Vec3> {
let denominator = ray_direction.dot(plane_normal);
if denominator.abs() <= RAY_EPSILON {
return None;
}
let t = (plane_origin - ray_origin).dot(plane_normal) / denominator;
if t < 0.0 {
return None;
}
Some(ray_origin + ray_direction * t)
}
/// Scalar distance a plane hit moved along `axis`.
pub fn axis_delta(start_hit: Vec3, current_hit: Vec3, axis: Vec3) -> f32 {
(current_hit - start_hit).dot(axis.normalize_or_zero())
}
/// Converts a world-space delta into the parent's local space.
///
/// M0 limit: only the parent's inverse rotation is applied; parent scale is
/// intentionally ignored. A scaled parent therefore changes the effective
/// world-space speed of the drag.
pub fn world_delta_to_local(world_delta: Vec3, parent_rotation: Quat) -> Vec3 {
parent_rotation.inverse() * world_delta
}
/// Computes the new parent-local translation for a drag from two plane hits.
pub fn dragged_translation(
start_translation: Vec3,
start_hit: Vec3,
current_hit: Vec3,
axis_world: Vec3,
parent_rotation: Quat,
) -> Vec3 {
let scalar = axis_delta(start_hit, current_hit, axis_world);
let world_delta = axis_world * scalar;
start_translation + world_delta_to_local(world_delta, parent_rotation)
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_approx(actual: f32, expected: f32) {
assert!(
(actual - expected).abs() < 1e-4,
"expected {expected}, got {actual}"
);
}
fn assert_vec3_approx(actual: Vec3, expected: Vec3) {
assert!(
actual.abs_diff_eq(expected, 1e-4),
"expected {expected:?}, got {actual:?}"
);
}
#[test]
fn point_segment_distance_inside_and_outside() {
let start = Vec2::new(0.0, 0.0);
let end = Vec2::new(10.0, 0.0);
// Perpendicular projection lands inside the segment.
assert_approx(point_segment_distance(Vec2::new(5.0, 3.0), start, end), 3.0);
// Outside the segment, nearest point is the start cap.
assert_approx(
point_segment_distance(Vec2::new(-4.0, 3.0), start, end),
5.0,
);
// Outside the segment, nearest point is the end cap.
assert_approx(
point_segment_distance(Vec2::new(14.0, -3.0), start, end),
5.0,
);
// Degenerate segment.
assert_approx(
point_segment_distance(Vec2::new(3.0, 4.0), start, start),
5.0,
);
}
#[test]
fn nearest_axis_is_picked_within_the_screen_threshold() {
let origin = Vec2::new(100.0, 100.0);
let endpoints = [
(GizmoAxis::X, Vec2::new(160.0, 100.0)),
(GizmoAxis::Y, Vec2::new(100.0, 40.0)),
(GizmoAxis::Z, Vec2::new(40.0, 100.0)),
];
assert_eq!(
pick_axis(Vec2::new(140.0, 104.0), origin, &endpoints, 12.0),
Some(GizmoAxis::X)
);
assert_eq!(
pick_axis(Vec2::new(100.0, 44.0), origin, &endpoints, 12.0),
Some(GizmoAxis::Y)
);
// Far away from every axis segment.
assert_eq!(
pick_axis(Vec2::new(100.0, 130.0), origin, &endpoints, 12.0),
None
);
}
#[test]
fn ray_plane_hit_returns_expected_point_and_rejects_parallel() {
let hit = ray_plane_hit(Vec3::new(0.0, 5.0, 0.0), Vec3::NEG_Y, Vec3::ZERO, Vec3::Y)
.expect("downward ray should hit the XZ plane");
assert_vec3_approx(hit, Vec3::ZERO);
// Parallel ray never intersects the plane.
assert_eq!(
ray_plane_hit(Vec3::ZERO, Vec3::X, Vec3::new(0.0, 1.0, 0.0), Vec3::Y),
None
);
// A hit behind the ray origin is not a valid hit.
assert_eq!(
ray_plane_hit(Vec3::new(0.0, 1.0, 0.0), Vec3::Y, Vec3::ZERO, Vec3::Y),
None
);
}
#[test]
fn axis_delta_projects_a_plane_hit_onto_the_axis() {
let start = Vec3::new(0.0, 0.0, 0.0);
let current = Vec3::new(2.5, 9.0, -4.0);
assert_approx(axis_delta(start, current, Vec3::X), 2.5);
assert_approx(axis_delta(start, current, Vec3::Y), 9.0);
// Non-normalized axis directions are normalized before projecting.
assert_approx(axis_delta(start, current, Vec3::Z * 2.0), -4.0);
}
#[test]
fn world_delta_to_local_uses_the_inverse_parent_rotation() {
// Parent rotated +90° around Y: a world +X delta is local +Z.
let parent_rotation = Quat::from_rotation_y(std::f32::consts::FRAC_PI_2);
let local = world_delta_to_local(Vec3::new(2.0, 0.0, 0.0), parent_rotation);
assert_vec3_approx(local, Vec3::new(0.0, 0.0, 2.0));
}
#[test]
fn drag_helper_changes_transform_by_expected_axis_delta() {
let start_translation = Vec3::new(1.0, 2.0, 3.0);
let start_hit = Vec3::ZERO;
let current_hit = Vec3::new(2.0, 7.0, -3.0);
let translation = dragged_translation(
start_translation,
start_hit,
current_hit,
Vec3::X,
Quat::IDENTITY,
);
let mut transform = Transform::from_translation(start_translation);
transform.translation = translation;
assert_vec3_approx(transform.translation, Vec3::new(3.0, 2.0, 3.0));
// Under a parent rotated +90° around Y, the same world delta becomes +2 on local Z.
let parent_rotation = Quat::from_rotation_y(std::f32::consts::FRAC_PI_2);
let translation = dragged_translation(
start_translation,
start_hit,
current_hit,
Vec3::X,
parent_rotation,
);
let mut transform = Transform::from_translation(start_translation);
transform.translation = translation;
assert_vec3_approx(transform.translation, Vec3::new(1.0, 2.0, 5.0));
}
#[test]
fn gizmo_length_scales_with_distance_and_clamps() {
assert_approx(gizmo_length(2.0), GIZMO_MIN_LENGTH);
assert_approx(gizmo_length(12.5), 2.25);
assert_approx(gizmo_length(40.0), GIZMO_MAX_LENGTH);
}
#[test]
fn adopted_camera_keeps_its_position() {
let transform =
Transform::from_xyz(6.0, 5.0, 10.0).looking_at(Vec3::new(0.0, 0.5, 0.0), Vec3::Y);
let camera = editor_camera_from_transform(&transform);
let rebuilt = camera_transform(&camera);
assert_vec3_approx(rebuilt.translation, transform.translation);
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);
}
}
+510
View File
@@ -0,0 +1,510 @@
//! Live hierarchy panel.
//!
//! Collects the scene tree from `ChildOf` / `Children`, renders it as an
//! indented, scrollable [`ListBox`] inside the left [`HierarchyPanel`] and binds
//! row activation to [`Selection`].
//!
//! The panel is rebuilt only when the scene snapshot changes. Editor-owned UI
//! entities, observers and window entities are not part of the scene snapshot,
//! so rebuilding the panel can never feed back into itself.
use std::collections::HashSet;
use bevy::{
picking::hover::Hovered,
prelude::*,
text::FontSize,
ui::{ScrollPosition, Selected},
ui_widgets::{ListBox, ListItem, ScrollArea, ValueChange},
};
use crate::{
plugin::{editor_active, Selection},
ui::{HierarchyPanel, UiTheme},
};
/// Largest depth a hierarchy row may have. Deeper descendants are not listed.
const MAX_DEPTH: usize = 64;
/// One row of the hierarchy tree, in render order.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HierarchyRowData {
/// Entity this row represents.
pub entity: Entity,
/// Display name: the entity's `Name`, or `Entity#{index}` when missing.
pub name: String,
/// Zero-based depth. Roots have depth `0`.
pub depth: usize,
}
/// Marker component on a list row, mapping it to the scene entity it selects.
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub struct HierarchyRow {
/// Scene entity selected when this row is activated.
pub target: Entity,
}
/// Marker for the single list entity inside the hierarchy panel.
#[derive(Component)]
struct HierarchyList;
/// One collected tree row including its parent, used for snapshot comparison.
#[derive(Debug, Clone, PartialEq, Eq)]
struct TreeRow {
entity: Entity,
name: String,
parent: Option<Entity>,
depth: usize,
}
/// Snapshot of the last rendered tree: `(entity, name, parent, depth)` in render order.
#[derive(Resource, Default)]
struct HierarchyTreeState {
signature: Vec<(Entity, String, Option<Entity>, usize)>,
rows: usize,
roots: usize,
}
/// Adds the live hierarchy panel.
pub struct HierarchyPlugin;
impl Plugin for HierarchyPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<HierarchyTreeState>().add_systems(
Update,
(rebuild_hierarchy, sync_selected_rows, style_hierarchy_rows)
.chain()
.run_if(editor_active),
);
}
}
/// Collects the full world tree: roots first, children in `Children` order.
///
/// Roots are entities without `ChildOf`, sorted by [`Entity`]. Unnamed entities
/// fall back to `Entity#{index}`. A visited set prevents duplicate rows and a
/// depth cap of [`MAX_DEPTH`] prevents pathological trees from stalling the UI.
pub fn collect_rows(world: &mut World) -> Vec<HierarchyRowData> {
collect_tree(world, false)
.into_iter()
.map(|row| HierarchyRowData {
entity: row.entity,
name: row.name,
depth: row.depth,
})
.collect()
}
/// Collects the scene tree: only named, non-UI spatial entities may be roots.
///
/// Children of a scene root are listed regardless of whether they have a
/// `Name`, so unnamed scene children still show up with the index fallback.
fn collect_scene_rows(world: &World) -> Vec<TreeRow> {
collect_tree(world, true)
}
/// Shared tree walk.
///
/// When `scene_only` is set, roots additionally require `Name` and reject
/// `Node`, which keeps the editor UI, global observers and window entities out
/// of the hierarchy (and out of the rebuild snapshot).
fn collect_tree(world: &World, scene_only: bool) -> Vec<TreeRow> {
let mut roots: Vec<Entity> = world
.iter_entities()
.filter_map(|entity| {
let id = entity.id();
if entity.get::<ChildOf>().is_some() {
return None;
}
if scene_only && !is_scene_root(world, id) {
return None;
}
Some(id)
})
.collect();
roots.sort();
let mut rows = Vec::new();
let mut visited = HashSet::new();
for root in roots {
push_subtree(world, root, None, 0, &mut visited, &mut rows);
}
rows
}
/// Whether an entity is a root of the editable scene rather than of the editor
/// or of an engine-internal helper (UI nodes, observers, window and gizmo
/// renderer entities, ...).
fn is_scene_root(world: &World, entity: Entity) -> bool {
world.get::<Name>(entity).is_some()
&& world.get::<Transform>(entity).is_some()
&& world.get::<Node>(entity).is_none()
}
/// Depth-first, in-order push of one entity and its descendants.
fn push_subtree(
world: &World,
entity: Entity,
parent: Option<Entity>,
depth: usize,
visited: &mut HashSet<Entity>,
rows: &mut Vec<TreeRow>,
) {
if depth > MAX_DEPTH || !visited.insert(entity) {
return;
}
let name = world
.get::<Name>(entity)
.map(|name| name.as_str().to_owned())
.unwrap_or_else(|| format!("Entity#{}", entity.index()));
let children: Vec<Entity> = world
.get::<Children>(entity)
.map(|children| children.to_vec())
.unwrap_or_default();
rows.push(TreeRow {
entity,
name,
parent,
depth,
});
if depth >= MAX_DEPTH {
return;
}
for child in children {
push_subtree(world, child, Some(entity), depth + 1, visited, rows);
}
}
/// Rebuilds the panel contents when the scene tree snapshot changes.
///
/// This is an exclusive system so the old list is despawned and the new one is
/// spawned in the same tick: `sync_selected_rows` never observes stale rows and
/// a rebuild can never run twice for one snapshot.
fn rebuild_hierarchy(world: &mut World) {
let rows = collect_scene_rows(world);
let root_count = rows.iter().filter(|row| row.depth == 0).count();
let signature: Vec<(Entity, String, Option<Entity>, usize)> = rows
.iter()
.map(|row| (row.entity, row.name.clone(), row.parent, row.depth))
.collect();
let previous = {
let state = world.resource::<HierarchyTreeState>();
(state.signature.clone(), state.rows, state.roots)
};
if previous.0 == signature {
return;
}
let panel = world
.query_filtered::<Entity, With<HierarchyPanel>>()
.iter(world)
.next();
let Some(panel) = panel else {
return;
};
let theme = world.resource::<UiTheme>().clone();
let selection = *world.resource::<Selection>();
// Drop the previous list (and its rows) before re-populating the panel.
let mut list_query = world.query_filtered::<Entity, With<HierarchyList>>();
let old_lists: Vec<Entity> = list_query.iter(world).collect();
drop(list_query);
for list in old_lists {
world.despawn(list);
}
// The shell's placeholder ("Waiting for scene...") is replaced by the list.
world.entity_mut(panel).despawn_children();
let header = world
.spawn(hierarchy_label("Hierarchy", 14.0, theme.text))
.id();
world.entity_mut(panel).add_child(header);
let list = world
.spawn((
HierarchyList,
ListBox,
ScrollArea,
ScrollPosition::default(),
Node {
width: percent(100),
flex_grow: 1.0,
flex_direction: FlexDirection::Column,
overflow: Overflow::scroll_y(),
..default()
},
BackgroundColor(Color::NONE),
))
.id();
world.entity_mut(list).observe(hierarchy_row_activated);
world.entity_mut(panel).add_child(list);
for row in &rows {
let is_selected = selection.0 == Some(row.entity);
let row_entity = world
.spawn((
ListItem,
HierarchyRow { target: row.entity },
Node {
width: percent(100),
height: px(22.0),
flex_shrink: 0.0,
padding: UiRect::left(px(6.0 + row.depth as f32 * 14.0)),
align_items: AlignItems::Center,
..default()
},
BackgroundColor(if is_selected {
theme.row_selected
} else {
Color::NONE
}),
Hovered::default(),
))
.id();
if is_selected {
world.entity_mut(row_entity).insert(Selected);
}
let text = world
.spawn(hierarchy_label(
row.name.clone(),
13.0,
if is_selected {
theme.text
} else {
theme.text_muted
},
))
.id();
world.entity_mut(row_entity).add_child(text);
world.entity_mut(list).add_child(row_entity);
}
let count_changed = previous.1 != rows.len() || previous.2 != root_count;
*world.resource_mut::<HierarchyTreeState>() = HierarchyTreeState {
signature,
rows: rows.len(),
roots: root_count,
};
if count_changed || previous.1 == 0 {
info!("hierarchy: {} rows, {} roots", rows.len(), root_count);
debug!(
"hierarchy rows: {:?}",
rows.iter().map(|row| row.name.as_str()).collect::<Vec<_>>()
);
}
}
/// Creates a plain editor label bundle.
fn hierarchy_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),
)
}
/// Observer on the list entity: a committed row activation selects its target.
fn hierarchy_row_activated(
change: On<ValueChange<Entity>>,
rows: Query<&HierarchyRow>,
mut selection: ResMut<Selection>,
) {
let value = change.event().value;
let Ok(row) = rows.get(value) else {
return;
};
if selection.0 != Some(row.target) {
selection.0 = Some(row.target);
}
}
/// Keeps the widget's `Selected` marker in sync with [`Selection`].
fn sync_selected_rows(
mut commands: Commands,
selection: Res<Selection>,
rows: Query<(Entity, &HierarchyRow, Has<Selected>)>,
) {
for (entity, row, selected) in &rows {
let should_select = selection.0 == Some(row.target);
if should_select && !selected {
commands.entity(entity).insert(Selected);
} else if !should_select && selected {
commands.entity(entity).remove::<Selected>();
}
}
}
/// Styles rows from [`UiTheme`] using the `Selected` marker and pointer hover.
fn style_hierarchy_rows(
theme: Res<UiTheme>,
selection: Res<Selection>,
mut rows: Query<(
&HierarchyRow,
&mut BackgroundColor,
Has<Selected>,
&Hovered,
&Children,
)>,
mut texts: Query<&mut TextColor>,
) {
for (row, mut background, selected, hovered, children) in &mut rows {
let is_selected = selected || selection.0 == Some(row.target);
let is_hovered = hovered.get() && !is_selected;
let background_color = if is_selected {
theme.row_selected
} else if is_hovered {
theme.row_hover
} else {
Color::NONE
};
if background.0 != background_color {
background.0 = background_color;
}
let text_color = if is_selected || is_hovered {
theme.text
} else {
theme.text_muted
};
for child in children.iter() {
if let Ok(mut color) = texts.get_mut(child) {
if color.0 != text_color {
color.0 = text_color;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Keeps only the rows for entities spawned by a test.
///
/// A bare [`World`] can contain internal entities (for example the reserved
/// index-zero entity); they are valid hierarchy roots but not part of these
/// focused ordering tests.
fn rows_for(world: &mut World, entities: &[Entity]) -> Vec<HierarchyRowData> {
collect_rows(world)
.into_iter()
.filter(|row| entities.contains(&row.entity))
.collect()
}
#[test]
fn collect_rows_walks_three_levels_in_children_order() {
let mut world = World::new();
let root = world.spawn(Name::new("Root")).id();
let child = world.spawn((Name::new("Child"), ChildOf(root))).id();
let grandchild = world.spawn((Name::new("Grandchild"), ChildOf(child))).id();
let sibling = world.spawn((Name::new("Sibling"), ChildOf(root))).id();
let rows = rows_for(&mut world, &[root, child, grandchild, sibling]);
let order: Vec<(&str, usize)> = rows
.iter()
.map(|row| (row.name.as_str(), row.depth))
.collect();
assert_eq!(
order,
vec![("Root", 0), ("Child", 1), ("Grandchild", 2), ("Sibling", 1),]
);
assert_eq!(rows[0].entity, root);
assert_eq!(rows[1].entity, child);
assert_eq!(rows[2].entity, grandchild);
assert_eq!(rows[3].entity, sibling);
}
#[test]
fn collect_rows_falls_back_to_entity_index() {
let mut world = World::new();
let root = world.spawn(Name::new("Root")).id();
let unnamed_child = world.spawn(ChildOf(root)).id();
let unnamed_root = world.spawn_empty().id();
let rows = rows_for(&mut world, &[root, unnamed_child, unnamed_root]);
assert_eq!(rows.len(), 3);
let row = |entity: Entity| {
rows.iter()
.find(|row| row.entity == entity)
.expect("row for entity")
};
assert_eq!(row(root).name, "Root");
assert_eq!(row(root).depth, 0);
assert_eq!(
row(unnamed_child).name,
format!("Entity#{}", unnamed_child.index())
);
assert_eq!(row(unnamed_child).depth, 1);
assert_eq!(
row(unnamed_root).name,
format!("Entity#{}", unnamed_root.index())
);
assert_eq!(row(unnamed_root).depth, 0);
}
#[test]
fn collect_rows_caps_depth_at_64() {
let mut world = World::new();
let mut parent = world.spawn(Name::new("0")).id();
let mut chain = vec![parent];
for depth in 1..70 {
parent = world
.spawn((Name::new(format!("{depth}")), ChildOf(parent)))
.id();
chain.push(parent);
}
let rows = rows_for(&mut world, &chain);
assert_eq!(rows.len(), MAX_DEPTH + 1);
assert_eq!(rows.last().expect("rows").depth, MAX_DEPTH);
assert!(rows.iter().all(|row| row.depth <= MAX_DEPTH));
}
#[test]
fn value_change_selects_the_row_target() {
let mut world = World::new();
world.init_resource::<Selection>();
let list = world.spawn(HierarchyList).id();
let target = world.spawn(Name::new("Target")).id();
let row = world.spawn((ListItem, HierarchyRow { target })).id();
world.entity_mut(list).observe(hierarchy_row_activated);
world.trigger(ValueChange::<Entity> {
source: list,
value: row,
is_final: true,
});
assert_eq!(world.resource::<Selection>().0, Some(target));
}
#[test]
fn value_change_ignores_entities_without_a_row() {
let mut world = World::new();
world.init_resource::<Selection>();
let list = world.spawn(HierarchyList).id();
let stranger = world.spawn(Name::new("Stranger")).id();
world.entity_mut(list).observe(hierarchy_row_activated);
world.trigger(ValueChange::<Entity> {
source: list,
value: stranger,
is_final: true,
});
assert_eq!(world.resource::<Selection>().0, None);
}
}
+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));
}
}
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
//! Bevy Forge live editor runtime (v2 live editor, M1 core).
//!
//! [`EditorPlugin`] installs the editor resources, the fixed Bevy UI shell
//! (toolbar, hierarchy panel, inspector panel, status bar), the scene bridge that
//! 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`:
//!
//! ```ignore
//! App::new()
//! .add_plugins(DefaultPlugins)
//! .add_plugins(EditorPlugin)
//! .run();
//! ```
pub mod assets;
pub mod export;
pub mod gizmo;
pub mod hierarchy;
pub mod history;
pub mod inspector;
pub mod play;
pub mod plugin;
pub mod scene;
pub mod screenshot;
pub mod shortcuts;
pub mod ui;
pub mod watcher;
pub use assets::AssetBrowserPlugin;
pub use export::ExportPlugin;
pub use gizmo::GizmoPlugin;
pub use hierarchy::HierarchyPlugin;
pub use history::HistoryPlugin;
pub use inspector::InspectorPlugin;
pub use play::PlayPlugin;
pub use plugin::{editor_active, EditorPlugin, EditorState, InspectorSource, Selection};
pub use scene::{SceneConfig, SceneDocument, ScenePlugin};
pub use screenshot::ScreenshotPlugin;
pub use shortcuts::ShortcutsPlugin;
pub use ui::{
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");
}
}
+160
View File
@@ -0,0 +1,160 @@
//! The root editor plugin and the shared editor resources.
use bevy::prelude::*;
use crate::{
assets::AssetBrowserPlugin, export::ExportPlugin, gizmo::GizmoPlugin,
hierarchy::HierarchyPlugin, history::HistoryPlugin, inspector::InspectorPlugin,
play::PlayPlugin, scene::ScenePlugin, screenshot::ScreenshotPlugin, shortcuts::ShortcutsPlugin,
ui::shell::EditorUiPlugin, watcher::WatcherPlugin,
};
/// Installs the Bevy Forge live editor on top of `DefaultPlugins`.
///
/// The plugin is usable exactly once per app. It does not overwrite the
/// [`EditorState`] or [`Selection`] resources when the host app has already
/// inserted them.
pub struct EditorPlugin;
impl Plugin for EditorPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<EditorState>()
.init_resource::<Selection>()
.add_plugins((
EditorUiPlugin,
ScenePlugin,
ShortcutsPlugin,
HistoryPlugin,
PlayPlugin,
AssetBrowserPlugin,
ExportPlugin,
WatcherPlugin,
HierarchyPlugin,
InspectorPlugin,
GizmoPlugin,
ScreenshotPlugin,
))
.add_systems(Update, clear_dead_selection);
}
}
/// Whether the editor UI and behavior are currently enabled.
#[derive(Resource, Debug, Clone)]
pub struct EditorState {
/// `true` while the editor is shown. Defaults to `true`.
pub enabled: bool,
}
impl Default for EditorState {
fn default() -> Self {
Self { enabled: true }
}
}
impl EditorState {
/// Flips the editor on or off.
pub fn toggle(&mut self) {
self.enabled = !self.enabled;
}
}
/// Current single selection. `Selection(None)` means nothing is selected.
#[derive(Resource, Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Selection(pub Option<Entity>);
/// Optional bridge between the inspector and host-app-specific reflection data.
///
/// M0 does not need a concrete implementation; T03 uses typed `Transform` /
/// `Name` / `Visibility` queries. The trait is part of the frozen public API so
/// M1 can plug in a component registry without changing the plugin surface.
pub trait InspectorSource {
/// Returns the reflected component data for `entity`, if any.
fn reflect_component(&self, entity: Entity) -> Option<&dyn Reflect>;
}
/// Run condition: systems gated on this only run while the editor is enabled.
pub fn editor_active(state: Res<EditorState>) -> bool {
state.enabled
}
/// Clears [`Selection`] when the selected entity was despawned.
fn clear_dead_selection(mut selection: ResMut<Selection>, entities: Query<Entity>) {
let cleared = clear_selection_if_dead(&mut selection, |entity| entities.get(entity).is_ok());
if cleared {
debug!("selection: cleared (entity no longer exists)");
}
}
/// Clears the selection if `entity_alive` reports that it is gone.
///
/// Returns `true` when a selection was cleared. Extracted so the cleanup rule
/// can be unit tested without configuring a full app.
pub(crate) fn clear_selection_if_dead(
selection: &mut Selection,
entity_alive: impl Fn(Entity) -> bool,
) -> bool {
let Some(entity) = selection.0 else {
return false;
};
if entity_alive(entity) {
return false;
}
selection.0 = None;
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn editor_state_defaults_to_enabled() {
assert!(EditorState::default().enabled);
}
#[test]
fn editor_state_toggles() {
let mut state = EditorState::default();
state.toggle();
assert!(!state.enabled);
state.toggle();
assert!(state.enabled);
}
#[test]
fn selection_defaults_to_none() {
assert_eq!(Selection::default().0, None);
}
#[test]
fn dead_selection_is_cleared() {
let mut selection = Selection(Some(Entity::PLACEHOLDER));
assert!(clear_selection_if_dead(&mut selection, |_| false));
assert_eq!(selection.0, None);
}
#[test]
fn live_selection_is_kept() {
let mut selection = Selection(Some(Entity::PLACEHOLDER));
assert!(!clear_selection_if_dead(&mut selection, |_| true));
assert_eq!(selection.0, Some(Entity::PLACEHOLDER));
}
#[test]
fn no_selection_is_a_no_op() {
let mut selection = Selection(None);
assert!(!clear_selection_if_dead(&mut selection, |_| false));
assert_eq!(selection.0, None);
}
#[test]
fn plugin_registers_resources_without_overwriting_existing_ones() {
let mut app = App::new();
app.insert_resource(EditorState { enabled: false });
app.add_plugins(EditorPlugin);
assert!(app.world().contains_resource::<Selection>());
assert_eq!(app.world().resource::<Selection>().0, None);
assert!(!app.world().resource::<EditorState>().enabled);
}
}
+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);
}
}
+374
View File
@@ -0,0 +1,374 @@
//! Deterministic screenshot plugin.
//!
//! Opt-in environment contract:
//!
//! | Variable | Default | Meaning |
//! |---|---|---|
//! | `FORGE_SCREENSHOT` | unset | output PNG path; unset disables the mode |
//! | `FORGE_SCREENSHOT_SIZE` | `1440x900` | `WIDTHxHEIGHT`, each `320..=7680` |
//! | `FORGE_SCREENSHOT_FRAMES` | `300` | frames before capture, `1..=1200` |
//! | `FORGE_SCREENSHOT_WARMUP_SECS` | `10` | wall-clock seconds, `0..=120` |
//!
//! Unset means a complete no-op: no systems, no window resize, no exit. Invalid
//! values log an error and disable the mode. Capture waits for both thresholds
//! because the probe showed mesh/PBR pipelines need about ten seconds to warm up
//! (a 30-frame shot is blank). Success saves the PNG and writes
//! [`AppExit::Success`]; if no capture is confirmed within 300 frames, a fallback
//! exits with an error.
use std::path::{Path, PathBuf};
use bevy::{
prelude::*,
render::view::screenshot::{save_to_disk, Screenshot, ScreenshotCaptured},
window::{PrimaryWindow, WindowResolution},
};
const PATH_ENV: &str = "FORGE_SCREENSHOT";
const SIZE_ENV: &str = "FORGE_SCREENSHOT_SIZE";
const FRAMES_ENV: &str = "FORGE_SCREENSHOT_FRAMES";
const WARMUP_ENV: &str = "FORGE_SCREENSHOT_WARMUP_SECS";
const DEFAULT_WIDTH: u32 = 1440;
const DEFAULT_HEIGHT: u32 = 900;
const DEFAULT_FRAMES: u32 = 300;
const DEFAULT_WARMUP_SECS: u32 = 10;
const MIN_DIMENSION: u32 = 320;
const MAX_DIMENSION: u32 = 7680;
const MIN_FRAMES: u32 = 1;
const MAX_FRAMES: u32 = 1200;
const MAX_WARMUP_SECS: u32 = 120;
const FALLBACK_FRAMES: u32 = 300;
/// Adds opt-in deterministic screenshot capture (`FORGE_SCREENSHOT`).
pub struct ScreenshotPlugin;
impl Plugin for ScreenshotPlugin {
fn build(&self, app: &mut App) {
match config_from_env() {
Ok(config) => configure(app, config),
Err(error) => error!("screenshot: {error}; screenshot mode disabled"),
}
}
}
#[derive(Resource, Debug, Clone, PartialEq, Eq)]
struct ScreenshotConfig {
path: PathBuf,
width: u32,
height: u32,
frames: u32,
warmup_secs: u32,
}
#[derive(Resource, Debug, Default)]
struct ScreenshotState {
frames_elapsed: u32,
capture_requested: bool,
frames_since_capture: u32,
exit_requested: bool,
}
/// Installs the mode only when `config` is `Some`; `None` is a complete no-op.
fn configure(app: &mut App, config: Option<ScreenshotConfig>) {
let Some(config) = config else {
return;
};
app.insert_resource(config)
.init_resource::<ScreenshotState>()
.add_systems(Startup, apply_screenshot_window)
.add_systems(Update, request_screenshot);
}
/// Forces the primary window to the requested physical size at scale factor 1.0.
fn apply_screenshot_window(
config: Res<ScreenshotConfig>,
mut windows: Query<&mut Window, With<PrimaryWindow>>,
) {
let Ok(mut window) = windows.single_mut() else {
error!(
"screenshot: no primary window; cannot set {}x{}",
config.width, config.height
);
return;
};
window.resolution =
WindowResolution::new(config.width, config.height).with_scale_factor_override(1.0);
info!(
"screenshot: window set to {}x{} (scale factor 1.0)",
config.width, config.height
);
}
/// Requests one capture after both thresholds, then exits on capture or fallback.
fn request_screenshot(
mut commands: Commands,
config: Res<ScreenshotConfig>,
mut state: ResMut<ScreenshotState>,
real_time: Res<Time<Real>>,
mut exit: MessageWriter<AppExit>,
) {
if state.exit_requested {
return;
}
if state.capture_requested {
state.frames_since_capture = state.frames_since_capture.saturating_add(1);
if state.frames_since_capture >= FALLBACK_FRAMES {
warn!("screenshot: capture was not confirmed within {FALLBACK_FRAMES} frames; exiting");
state.exit_requested = true;
exit.write(AppExit::error());
}
return;
}
state.frames_elapsed = state.frames_elapsed.saturating_add(1);
let warmup_elapsed = real_time.elapsed_secs_f64() >= f64::from(config.warmup_secs);
if state.frames_elapsed < config.frames || !warmup_elapsed {
return;
}
if let Err(error) = create_parent_dirs(&config.path) {
error!("screenshot: {error}; exiting");
state.exit_requested = true;
exit.write(AppExit::error());
return;
}
info!(
"screenshot: saving {} ({}x{})",
config.path.display(),
config.width,
config.height
);
commands
.spawn(Screenshot::primary_window())
.observe(save_to_disk(config.path.clone()))
.observe(exit_after_screenshot);
state.capture_requested = true;
}
fn exit_after_screenshot(_captured: On<ScreenshotCaptured>, mut exit: MessageWriter<AppExit>) {
exit.write(AppExit::Success);
}
fn create_parent_dirs(path: &Path) -> Result<(), String> {
match path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => {
std::fs::create_dir_all(parent).map_err(|error| {
format!(
"cannot create output directory {}: {error}",
parent.display()
)
})
}
_ => Ok(()),
}
}
/// Reads the environment once; the path is checked first so unset stays a no-op
/// even when one of the other variables is malformed.
fn config_from_env() -> Result<Option<ScreenshotConfig>, String> {
let Some(path) = std::env::var_os(PATH_ENV) else {
return Ok(None);
};
let path = PathBuf::from(path);
let size = env_string(SIZE_ENV)?;
let frames = env_string(FRAMES_ENV)?;
let warmup_secs = env_string(WARMUP_ENV)?;
parse_config(
Some(&path),
size.as_deref(),
frames.as_deref(),
warmup_secs.as_deref(),
)
}
fn env_string(name: &str) -> Result<Option<String>, String> {
match std::env::var(name) {
Ok(value) => Ok(Some(value)),
Err(std::env::VarError::NotPresent) => Ok(None),
Err(std::env::VarError::NotUnicode(_)) => Err(format!("{name} must be valid UTF-8")),
}
}
/// Returns `Ok(None)` when the path is absent; `Err` when it is malformed.
fn parse_config(
path: Option<&Path>,
size: Option<&str>,
frames: Option<&str>,
warmup_secs: Option<&str>,
) -> Result<Option<ScreenshotConfig>, String> {
let Some(path) = path else {
return Ok(None);
};
if path.as_os_str().is_empty() {
return Err(format!("{PATH_ENV} must not be empty"));
}
let (width, height) = match size {
Some(raw) => parse_size(raw)?,
None => (DEFAULT_WIDTH, DEFAULT_HEIGHT),
};
let frames = match frames {
Some(raw) => parse_decimal(FRAMES_ENV, raw, MIN_FRAMES, MAX_FRAMES)?,
None => DEFAULT_FRAMES,
};
let warmup_secs = match warmup_secs {
Some(raw) => parse_decimal(WARMUP_ENV, raw, 0, MAX_WARMUP_SECS)?,
None => DEFAULT_WARMUP_SECS,
};
Ok(Some(ScreenshotConfig {
path: path.to_path_buf(),
width,
height,
frames,
warmup_secs,
}))
}
fn parse_size(raw: &str) -> Result<(u32, u32), String> {
let Some((width, height)) = raw.split_once('x') else {
return Err(format!("{SIZE_ENV} must be WIDTHxHEIGHT, got {raw:?}"));
};
Ok((
parse_decimal("width", width, MIN_DIMENSION, MAX_DIMENSION)
.map_err(|error| format!("{SIZE_ENV}: {error}"))?,
parse_decimal("height", height, MIN_DIMENSION, MAX_DIMENSION)
.map_err(|error| format!("{SIZE_ENV}: {error}"))?,
))
}
fn parse_decimal(label: &str, raw: &str, min: u32, max: u32) -> Result<u32, String> {
if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(format!("{label} must be a decimal integer, got {raw:?}"));
}
let value: u32 = raw
.parse()
.map_err(|_| format!("{label} is out of range, got {raw:?}"))?;
if !(min..=max).contains(&value) {
return Err(format!("{label} must be in {min}..={max}, got {value}"));
}
Ok(value)
}
#[cfg(test)]
mod tests {
use super::*;
fn path() -> &'static Path {
Path::new("/tmp/forge-screenshot.png")
}
#[test]
fn screenshot_defaults_are_1440x900_300_frames_10_seconds() {
let config = parse_config(Some(path()), None, None, None)
.expect("defaults must parse")
.expect("a path enables the mode");
assert_eq!(
(config.width, config.height),
(DEFAULT_WIDTH, DEFAULT_HEIGHT)
);
assert_eq!((config.width, config.height), (1440, 900));
assert_eq!(config.frames, 300);
assert_eq!(config.warmup_secs, 10);
assert_eq!(config.path, PathBuf::from(path()));
}
#[test]
fn screenshot_parses_valid_size_and_limits() {
let config = parse_config(Some(path()), Some("1000x640"), Some("12"), Some("0"))
.expect("valid values must parse")
.expect("a path enables the mode");
assert_eq!((config.width, config.height), (1000, 640));
assert_eq!(config.frames, 12);
assert_eq!(config.warmup_secs, 0);
let min = parse_config(Some(path()), Some("320x320"), Some("1"), Some("120"))
.unwrap()
.unwrap();
assert_eq!((min.width, min.height), (320, 320));
assert_eq!((min.frames, min.warmup_secs), (1, 120));
let max = parse_config(Some(path()), Some("7680x7680"), Some("1200"), None)
.unwrap()
.unwrap();
assert_eq!((max.width, max.height), (7680, 7680));
assert_eq!(max.frames, 1200);
}
#[test]
fn screenshot_rejects_invalid_sizes() {
for raw in [
"",
"abc",
"1440",
"1440x",
"x900",
"1440X900",
"1440 x 900",
"0x900",
"1440x0",
"319x900",
"1440x319",
"7681x900",
"1440x7681",
"1440x900x2",
"-1440x900",
] {
assert!(
parse_config(Some(path()), Some(raw), None, None).is_err(),
"{raw:?} must be rejected"
);
}
}
#[test]
fn screenshot_rejects_invalid_frames_and_warmup() {
for raw in ["", "abc", "0", "1201", "-1", "300.0"] {
assert!(
parse_config(Some(path()), None, Some(raw), None).is_err(),
"frames {raw:?} must be rejected"
);
}
for raw in ["", "abc", "121", "-1", "10.0"] {
assert!(
parse_config(Some(path()), None, None, Some(raw)).is_err(),
"warmup {raw:?} must be rejected"
);
}
}
#[test]
fn screenshot_unset_path_is_disabled_even_with_invalid_values() {
let config = parse_config(None, Some("not-a-size"), Some("0"), Some("999"))
.expect("an absent path must short-circuit validation");
assert!(config.is_none());
}
#[test]
fn screenshot_unset_mode_touches_no_window_and_installs_no_systems() {
let config = parse_config(None, None, None, None).expect("unset is not an error");
assert!(config.is_none());
let mut app = App::new();
configure(&mut app, config);
app.update();
assert!(!app.world().contains_resource::<ScreenshotConfig>());
assert!(!app.world().contains_resource::<ScreenshotState>());
let mut windows = app.world_mut().query::<&Window>();
assert_eq!(windows.iter(app.world()).count(), 0);
let mut screenshots = app.world_mut().query::<&Screenshot>();
assert_eq!(screenshots.iter(app.world()).count(), 0);
}
#[test]
fn screenshot_enabled_config_installs_mode_state() {
let config = parse_config(Some(path()), Some("1000x640"), None, None)
.unwrap()
.unwrap();
let mut app = App::new();
configure(&mut app, Some(config));
assert!(app.world().contains_resource::<ScreenshotConfig>());
assert!(app.world().contains_resource::<ScreenshotState>());
assert_eq!(app.world().resource::<ScreenshotConfig>().width, 1000);
}
}
+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);
}
}
+10
View File
@@ -0,0 +1,10 @@
//! Editor UI: the fixed shell, its layout metrics and the shared theme.
pub(crate) mod shell;
pub mod theme;
pub use shell::{
EditorLayout, EditorRoot, EntityCountText, HierarchyPanel, InspectorPanel, StatusBar,
StatusText, Toolbar,
};
pub use theme::UiTheme;
+377
View File
@@ -0,0 +1,377 @@
//! Fixed editor UI shell: toolbar, hierarchy panel, inspector panel and status bar.
//!
//! The center of the window is deliberately not a UI node: the 3D camera renders
//! full-window behind the panels and [`EditorLayout::viewport_rect`] is the
//! authoritative rectangle for viewport-only input (used by the gizmo).
use bevy::{
prelude::*,
text::FontSize,
ui_widgets::{Activate, Button as UiButton},
};
use crate::{
plugin::{EditorState, Selection},
ui::theme::UiTheme,
};
/// Full-window root of the editor overlay. Hidden as a whole when the editor is disabled.
#[derive(Component)]
pub struct EditorRoot;
/// Left-hand scene hierarchy panel.
#[derive(Component)]
pub struct HierarchyPanel;
/// Right-hand inspector panel.
#[derive(Component)]
pub struct InspectorPanel;
/// Top toolbar.
#[derive(Component)]
pub struct Toolbar;
/// Bottom status bar.
#[derive(Component)]
pub struct StatusBar;
/// The status bar's primary text node.
#[derive(Component)]
pub struct StatusText;
/// Toolbar text that reports how many scene entities exist.
#[derive(Component)]
pub struct EntityCountText;
/// Screen-space metrics of the overlay panels.
///
/// The viewport is the area left uncovered by the panels:
/// `x = left`, `y = top`, `width = window - left - right`,
/// `height = window - top - bottom`.
#[derive(Resource, Debug, Clone, Copy)]
pub struct EditorLayout {
/// Width of the left hierarchy panel in logical pixels.
pub left: f32,
/// Width of the right inspector panel in logical pixels.
pub right: f32,
/// Height of the top toolbar in logical pixels.
pub top: f32,
/// Height of the bottom status bar in logical pixels.
pub bottom: f32,
}
impl Default for EditorLayout {
fn default() -> Self {
Self {
left: 260.0,
right: 300.0,
top: 38.0,
bottom: 26.0,
}
}
}
impl EditorLayout {
/// Rectangle of the 3D viewport in logical window coordinates.
///
/// Width and height are clamped to be non-negative for tiny windows.
pub fn viewport_rect(&self, window: &Window) -> Rect {
let width = (window.width() - self.left - self.right).max(0.0);
let height = (window.height() - self.top - self.bottom).max(0.0);
Rect::new(self.left, self.top, self.left + width, self.top + height)
}
}
/// Installs the fixed editor shell and its state-sync systems.
pub(crate) struct EditorUiPlugin;
impl Plugin for EditorUiPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<UiTheme>()
.init_resource::<EditorLayout>()
.add_systems(Startup, build_shell)
.add_systems(
Update,
(
toggle_editor_f1,
sync_editor_visibility,
update_status_text,
update_entity_count,
)
.chain(),
);
}
}
/// Builds the editor overlay. Runs at `Startup`, before host-app scene spawning.
fn build_shell(mut commands: Commands, theme: Res<UiTheme>, layout: Res<EditorLayout>) {
commands
.spawn((
EditorRoot,
Node {
position_type: PositionType::Absolute,
width: percent(100),
height: percent(100),
..default()
},
BackgroundColor(Color::NONE),
))
.with_children(|root| {
// --- Toolbar ---------------------------------------------------
root.spawn((
Toolbar,
Node {
position_type: PositionType::Absolute,
top: px(0),
left: px(0),
right: px(0),
height: px(layout.top),
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: px(10),
padding: UiRect::horizontal(px(10)),
border: UiRect::bottom(px(1)),
..default()
},
BackgroundColor(theme.toolbar_bg),
BorderColor::all(theme.border),
))
.with_children(|bar| {
bar.spawn(label("Bevy Forge", 15.0, theme.accent));
bar.spawn((
UiButton,
Node {
height: px(24),
padding: UiRect::horizontal(px(10)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border: UiRect::all(px(1)),
..default()
},
BackgroundColor(theme.panel_bg_alt),
BorderColor::all(theme.border),
))
.observe(toggle_editor_on_activate)
.with_children(|button| {
button.spawn(label("Editor (F1)", 13.0, theme.text));
});
bar.spawn(Node {
flex_grow: 1.0,
..default()
});
bar.spawn((
EntityCountText,
label("Entities: 0", 12.0, theme.text_muted),
));
});
// --- Left hierarchy panel --------------------------------------
root.spawn((
HierarchyPanel,
Node {
position_type: PositionType::Absolute,
left: px(0),
top: px(layout.top),
bottom: px(layout.bottom),
width: px(layout.left),
flex_direction: FlexDirection::Column,
padding: UiRect::all(px(8)),
row_gap: px(4),
border: UiRect::right(px(1)),
..default()
},
BackgroundColor(theme.panel_bg),
BorderColor::all(theme.border),
))
.with_children(|panel| {
panel.spawn(label("Hierarchy", 14.0, theme.text));
panel.spawn(label("Waiting for scene...", 11.0, theme.text_muted));
});
// --- Right inspector panel -------------------------------------
root.spawn((
InspectorPanel,
Node {
position_type: PositionType::Absolute,
right: px(0),
top: px(layout.top),
bottom: px(layout.bottom),
width: px(layout.right),
flex_direction: FlexDirection::Column,
padding: UiRect::all(px(8)),
row_gap: px(4),
border: UiRect::left(px(1)),
..default()
},
BackgroundColor(theme.panel_bg),
BorderColor::all(theme.border),
))
.with_children(|panel| {
panel.spawn(label("Inspector", 14.0, theme.text));
panel.spawn(label("Waiting for selection...", 11.0, theme.text_muted));
});
// --- Bottom status bar -----------------------------------------
root.spawn((
StatusBar,
Node {
position_type: PositionType::Absolute,
left: px(0),
right: px(0),
bottom: px(0),
height: px(layout.bottom),
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
padding: UiRect::horizontal(px(10)),
border: UiRect::top(px(1)),
..default()
},
BackgroundColor(theme.status_bg),
BorderColor::all(theme.border),
))
.with_children(|bar| {
bar.spawn((
StatusText,
label(
"No selection | Editor: on | F1 editor",
12.0,
theme.text_muted,
),
));
});
});
info!("editor shell ready (toolbar, hierarchy, inspector, status)");
}
/// Builds a UI label bundle 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),
)
}
/// Flips [`EditorState`] when the toolbar button is activated.
fn toggle_editor_on_activate(_activate: On<Activate>, mut state: ResMut<EditorState>) {
state.toggle();
}
/// F1 toggles the editor; the toolbar button uses the same state.
fn toggle_editor_f1(keys: Res<ButtonInput<KeyCode>>, mut state: ResMut<EditorState>) {
if keys.just_pressed(KeyCode::F1) {
state.toggle();
}
}
/// Matches the [`EditorRoot`] display mode to [`EditorState`].
///
/// The root is hidden as a whole; child panels keep their own display mode.
fn sync_editor_visibility(state: Res<EditorState>, mut roots: Query<&mut Node, With<EditorRoot>>) {
if !state.is_changed() {
return;
}
let display = if state.enabled {
Display::Flex
} else {
Display::None
};
for mut node in &mut roots {
if node.display != display {
node.display = display;
}
}
}
/// Updates the status line from the current selection, the selected entity's
/// `Name` and the editor state.
fn update_status_text(
mut status: Query<&mut Text, With<StatusText>>,
selection: Res<Selection>,
names: Query<&Name>,
state: Res<EditorState>,
) {
let mut value = match selection.0 {
Some(entity) => match names.get(entity) {
Ok(name) => format!("Selected: {}", name.as_str()),
Err(_) => format!("Selected: {entity}"),
},
None => "No selection".to_string(),
};
value.push_str(if state.enabled {
" | Editor: on | F1 editor"
} else {
" | Editor: off | F1 editor"
});
for mut text in &mut status {
if text.0 != value {
text.0 = value.clone();
}
}
}
/// Updates the toolbar entity count for named, non-UI scene entities.
fn update_entity_count(
mut counters: Query<&mut Text, With<EntityCountText>>,
scene_entities: Query<(), (With<Name>, With<Transform>, Without<Node>)>,
) {
let value = format!("Entities: {}", scene_entities.iter().count());
for mut text in &mut counters {
if text.0 != value {
text.0 = value.clone();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::window::WindowResolution;
fn window(width: u32, height: u32) -> Window {
Window {
resolution: WindowResolution::new(width, height).with_scale_factor_override(1.0),
..default()
}
}
#[test]
fn default_layout_matches_contract() {
let layout = EditorLayout::default();
assert_eq!(layout.left, 260.0);
assert_eq!(layout.right, 300.0);
assert_eq!(layout.top, 38.0);
assert_eq!(layout.bottom, 26.0);
}
#[test]
fn viewport_rect_1440x900() {
let rect = EditorLayout::default().viewport_rect(&window(1440, 900));
assert_eq!(rect.min, Vec2::new(260.0, 38.0));
assert_eq!(rect.max, Vec2::new(1140.0, 874.0));
assert_eq!(rect.size(), Vec2::new(880.0, 836.0));
}
#[test]
fn viewport_rect_1000x640() {
let rect = EditorLayout::default().viewport_rect(&window(1000, 640));
assert_eq!(rect.min, Vec2::new(260.0, 38.0));
assert_eq!(rect.max, Vec2::new(700.0, 614.0));
assert_eq!(rect.size(), Vec2::new(440.0, 576.0));
}
#[test]
fn viewport_rect_clamps_tiny_windows() {
let rect = EditorLayout::default().viewport_rect(&window(400, 100));
assert_eq!(rect.size(), Vec2::new(0.0, 36.0));
assert!(rect.size().cmple(Vec2::new(400.0, 100.0)).all());
}
}
+51
View File
@@ -0,0 +1,51 @@
//! Dark slate color theme shared by the editor UI.
use bevy::prelude::*;
/// Colors used by the editor shell and by later hierarchy/inspector/gizmo work.
///
/// All colors are stored as [`Color`] so widgets can use them directly in
/// `TextColor`, `BackgroundColor` and `BorderColor` components.
#[derive(Resource, Debug, Clone)]
pub struct UiTheme {
/// Background of the left and right panels.
pub panel_bg: Color,
/// Alternate background used for buttons and rows.
pub panel_bg_alt: Color,
/// Border and divider color.
pub border: Color,
/// Primary text color.
pub text: Color,
/// Secondary text color for hints and read-only values.
pub text_muted: Color,
/// Accent color for the title, selection and gizmo hints.
pub accent: Color,
/// Row background on hover.
pub row_hover: Color,
/// Row background while selected.
pub row_selected: Color,
/// Toolbar background.
pub toolbar_bg: Color,
/// Status bar background.
pub status_bg: Color,
/// Warning / destructive color.
pub danger: Color,
}
impl Default for UiTheme {
fn default() -> Self {
Self {
panel_bg: Color::srgba(0.074, 0.086, 0.102, 0.94),
panel_bg_alt: Color::srgb(0.121, 0.141, 0.172),
border: Color::srgba(0.258, 0.290, 0.337, 0.85),
text: Color::srgb(0.878, 0.898, 0.925),
text_muted: Color::srgb(0.576, 0.615, 0.674),
accent: Color::srgb(0.309, 0.576, 0.949),
row_hover: Color::srgb(0.168, 0.200, 0.247),
row_selected: Color::srgb(0.196, 0.333, 0.510),
toolbar_bg: Color::srgba(0.098, 0.113, 0.137, 0.96),
status_bg: Color::srgba(0.086, 0.098, 0.118, 0.96),
danger: Color::srgb(0.851, 0.298, 0.278),
}
}
}
+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: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 543 KiB

+163
View File
@@ -0,0 +1,163 @@
# Bevy Forge v2 — M0 Spike
Status: **M0 spike**, not a release. It proves the v2 architecture: a real in-game Bevy 0.20 editor
plugin with native Bevy UI, a live hierarchy, a reflection inspector, one translate gizmo and
deterministic screenshots. No egui is involved in the runtime crate.
![Bevy Forge M0 spike](m0-spike-1440x900.png)
_1000×640 variant: [m0-spike-1000x640.png](m0-spike-1000x640.png)._
## What M0 delivers
- `bevy_forge_runtime` — a new crate in the workspace, added to a game with
`.add_plugins(EditorPlugin)` after `DefaultPlugins`.
- Fixed editor shell: toolbar, hierarchy (left), inspector (right), status bar; F1 or the toolbar
button toggles the whole editor.
- Live hierarchy: `Children`/`ChildOf` tree, indentation, scrolling, click selection, selected-row
highlight.
- Reflection inspector for `Name`, `Transform` and `Visibility`; translation X/Y/Z are editable via
`bevy_ui_widgets` sliders and update the ECS entity. Rotation/scale/visibility are read-only in
M0.
- Editor camera (right-drag orbit, middle-drag pan, wheel zoom) and a translate gizmo with
screen-space axis picking and ray/plane dragging.
- Deterministic screenshot mode at fixed sizes with a scale factor override.
- A small hardcoded 3D demo scene (nine named scene entities plus the editor camera) in
`crates/bevy_forge_runtime/examples/editor_spike.rs`.
## Requirements
- Rust 1.85+ (the workspace pins `rust-version = "1.85"`; this environment used 1.99).
- Linux with Vulkan/GL and a windowing session (Wayland or X11). The reference run is Wayland with
an NVIDIA RTX 3050; the UI and screenshots also work through the probe on this machine.
- No Bevy installation is required; Bevy 0.20.0 is a normal Cargo dependency.
## Run
```bash
cd bevy-forge
cargo run -p bevy_forge_runtime --example editor_spike
```
The first run compiles the Bevy UI/PBR pipeline and is slow. The window opens at 1440×900.
## Controls
| Input | Action |
|---|---|
| F1 / toolbar **Editor (F1)** | Show or hide the editor UI |
| Left click a hierarchy row | Select that entity |
| Left drag a gizmo axis | Translate the selected entity along X/Y/Z |
| Right drag in the viewport | Orbit the editor camera |
| Middle drag in the viewport | Pan the editor camera |
| Mouse wheel | Zoom the editor camera |
The inspector sliders edit `Transform.translation.x/y/z` in the range -20..=20 with 0.1 steps.
A gizmo drag updates the ECS `Transform`, and the inspector sliders follow.
## Deterministic screenshots
The screenshot mode is opt-in and exits after saving:
```bash
FORGE_SCREENSHOT=$PWD/.swarm/m0/evidence/m0-1440x900.png \
FORGE_SCREENSHOT_SIZE=1440x900 \
cargo run -p bevy_forge_runtime --example editor_spike
FORGE_SCREENSHOT=$PWD/.swarm/m0/evidence/m0-1000x640.png \
FORGE_SCREENSHOT_SIZE=1000x640 \
cargo run -p bevy_forge_runtime --example editor_spike
```
| Variable | Default | Meaning |
|---|---|---|
| `FORGE_SCREENSHOT` | unset | output PNG; unset disables the mode |
| `FORGE_SCREENSHOT_SIZE` | `1440x900` | `WIDTHxHEIGHT`, 320..=7680 |
| `FORGE_SCREENSHOT_FRAMES` | `300` | minimum frames before capture |
| `FORGE_SCREENSHOT_WARMUP_SECS` | `10` | minimum wall-clock seconds before capture |
The warmup is deliberate: UI/gizmo shaders are ready quickly, but the 3D mesh/PBR pipeline can take
~10 s to compile on a cold run. A 30-frame screenshot is blank; the mode waits for both thresholds.
The window is forced to `WindowResolution::new(w, h).with_scale_factor_override(1.0)`, so the PNG is
exactly the requested size on HiDPI displays.
**Determinism caveat:** the hierarchy hover highlight follows the pointer. Park the cursor outside
the editor window before capturing if you need byte-identical pixels; the committed evidence was
captured that way. The screenshot mode fixes size, scene and frame timing, but does not currently
neutralise pointer hover.
## Architecture
```
crates/bevy_forge_runtime/
├── src/lib.rs public prelude: EditorPlugin, EditorState, Selection, InspectorSource
├── src/plugin.rs resources, plugin registration, selection cleanup
├── src/ui/ fixed shell, theme, markers, EditorLayout
├── src/hierarchy.rs live ListBox/ScrollArea tree + selection observer
├── src/inspector.rs reflection summary/apply, transform sliders, live sync
├── src/gizmo.rs editor camera, axes drawing, picking/drag
├── src/screenshot.rs deterministic PNG mode
└── examples/editor_spike.rs
```
The editor UI is an overlay: the 3D camera renders full-window and the panels cover the left/right
edges. `EditorLayout::viewport_rect` defines the center rectangle used by gizmo input.
## M0 acceptance
The full evidence is in `.swarm/m0/reviews/V01.md` and `.swarm/m0/final-report.md`; the commands
are listed in `.swarm/m0/plan.md`. In short:
- `cargo check -p bevy_forge_runtime` passes; `cargo test -p bevy_forge_runtime` is **38 passed,
0 failed, 0 ignored** plus one ignored doctest; the full workspace is **384 passed, 0 failed,
2 ignored** (v1 + M0).
- `cargo tree -p bevy_forge_runtime -i egui` finds no egui; a plain tree grep for `egui` is empty.
The v1 editor still uses egui; the runtime crate does not. `bevy_ui_widgets` resolves to 0.20.0.
- The example launches, logs `editor shell ready`, `hierarchy: 10 rows, 7 roots`,
`inspector: selected ... (Hero Cube)` and `gizmo: camera ready`, with no warnings/panics.
- The gizmo is visible on the selected entity; its drag math is unit-tested (including a rotated
parent). Computer-use on 2026-10-27 clicked hierarchy rows, dragged the inspector X slider and
the X gizmo axis, toggled the UI and zoomed the camera; selection, inspector, ECS and gizmo
followed, with no panic.
- Screenshots are exactly 1440×900 and 1000×640; both show the shell, hierarchy, inspector and gizmo.
- No v1/core/CLI source changes; only the workspace member/lock, the new crate, docs and README.
- Independent review: **pass_with_risks** (`.swarm/m0/reviews/V01.md`, local coordination artifact).
All acceptance gates pass; the residual risks are right/middle orbit-pan (not scripted) and the
disclosed hierarchy root filter.
## Interactive verification (computer-use, 2026-10-27)
The editor was driven through the KDE Wayland remote-desktop portal with the computer-use tool:
- clicked `Red Cube`, `Blue Cube`, `Ground` and `Hero Cube` in the hierarchy — selection, inspector
fields and gizmo moved to the clicked entity every time;
- dragged the Red Cube Translation X slider from −2.6 to 3.2 — the cube and gizmo moved and the
inspector value updated;
- dragged the Hero Cube X gizmo axis from 0.0 to 1.0 — the cube moved and the inspector slider
followed;
- the toolbar button hid the UI; F1 restored it; wheel zoom changed the camera distance.
This found a real bug that unit tests missed: on a selection change, the inspector's transform-sync
system ran after the panel rebuild and queued a `SliderValue` insert for a slider that had just been
despawned, panicking in Bevy's command queue. The systems are now chained **sync-first, then
rebuild**; repeated selection/slider/gizmo runs are clean. Orbit and pan (right/middle drag) are not
scripted by the portal drag helper and remain a manual check; wheel zoom is covered.
## Limits (M0, by design)
- No save/load of `.forge.ron`, no BSN export from the UI, no undo/redo, no play/pause, no asset
browser, no docking, no multi-select, no rotate/scale gizmo.
- The demo scene is hardcoded; `bevy_forge_core` is not a runtime dependency yet.
- The live hierarchy panel lists named, transformed scene roots and their descendants; unnamed root
entities are omitted in M0 (a full-world mode is M1 work). `collect_rows` remains the literal tree
model for tests.
- The inspector covers `Name`/`Transform`/`Visibility`; generic `TypeRegistry` component discovery
is M1 work.
- Parent scale is ignored during gizmo drags (M0); rotations are honored.
- 3D only in the demo; the v2 plan adds 2D and scene loading in M1/M2.
## Next
M1 Core: `.forge.ron` save/load via `bevy_forge_core`, selection API/multi-select, undo/redo command
stack, editor camera polish, and the first integration tests against the real scene format. Do not
start M1 until M0 is accepted by the verifier and the actual request/token spend is recorded.
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.
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.