V2 live editor #1
Generated
+2924
-64
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ members = [
|
||||
"crates/bevy_forge_core",
|
||||
"crates/bevy_forge_editor",
|
||||
"crates/bevy_forge_cli",
|
||||
"crates/bevy_forge_runtime",
|
||||
]
|
||||
exclude = ["compat/bsn-smoke"]
|
||||
resolver = "2"
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
| 2D scene editing | 3D wireframe preview |
|
||||
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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.
|
||||
|
||||

|
||||
|
||||
_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
@@ -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.
|
||||
|
||||

|
||||
|
||||
_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 |
@@ -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.
|
||||
|
||||

|
||||
|
||||
_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.
|
||||
Reference in New Issue
Block a user