feat(runtime): add M1 scene persistence, undo/redo and hotkeys
- load .forge.ron through bevy_forge_core into the live ECS (3D components) - Ctrl+S syncs and saves the document; custom components are preserved - transform undo/redo for inspector sliders and gizmo drags (Ctrl+Z/Y) - F focus, Delete subtree, Ctrl+D duplicate; camera subtrees are guarded - 66 runtime tests; workspace 412 passed; garden demo docs/screenshots
This commit is contained in:
Generated
+1
@@ -991,6 +991,7 @@ name = "bevy_forge_runtime"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bevy",
|
"bevy",
|
||||||
|
"bevy_forge_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -38,6 +38,20 @@ cargo run -p bevy_forge_runtime --example editor_spike
|
|||||||
See [docs/m0-spike.md](docs/m0-spike.md) for controls, architecture and M0 limits. The v1 standalone
|
See [docs/m0-spike.md](docs/m0-spike.md) for controls, architecture and M0 limits. The v1 standalone
|
||||||
editor below remains the current released workflow.
|
editor below remains the current released workflow.
|
||||||
|
|
||||||
|
### M1 Core — real scene load/save + undo
|
||||||
|
|
||||||
|
M1 loads a real `.forge.ron` scene through `bevy_forge_core`, saves edits with Ctrl+S, adds
|
||||||
|
Transform undo/redo (Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y) and the F focus, Delete and Ctrl+D duplicate
|
||||||
|
hotkeys.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
FORGE_PROJECT=examples/demo-project \
|
||||||
|
FORGE_SCENE=scenes/garden.forge.ron \
|
||||||
|
cargo run -p bevy_forge_runtime --example editor_live
|
||||||
|
```
|
||||||
|
|
||||||
|
See [docs/m1-core.md](docs/m1-core.md) for the garden demo, hotkeys and M1 limits.
|
||||||
|
|
||||||
## Two viewports
|
## Two viewports
|
||||||
|
|
||||||
| 2D scene editing | 3D wireframe preview |
|
| 2D scene editing | 3D wireframe preview |
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ license.workspace = true
|
|||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
publish = false
|
publish = false
|
||||||
description = "Live in-game scene editor plugin for Bevy 0.20 (M0 spike)"
|
description = "Live in-game scene editor plugin for Bevy 0.20 (M0/M1)"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
bevy_forge_core = { path = "../bevy_forge_core" }
|
||||||
# Frozen M0 feature set. Do not add `tonemapping_luts` (ktx2/zstd) — cameras use
|
# Frozen M0 feature set. Do not add `tonemapping_luts` (ktx2/zstd) — cameras use
|
||||||
# `Tonemapping::AcesFitted` instead of the LUT-based default.
|
# `Tonemapping::AcesFitted` instead of the LUT-based default.
|
||||||
bevy = { version = "0.20", default-features = false, features = [
|
bevy = { version = "0.20", default-features = false, features = [
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
//! M1 live editor: the editor plugin on top of `DefaultPlugins`, loading a real
|
||||||
|
//! `.forge.ron` scene selected through the environment.
|
||||||
|
//!
|
||||||
|
//! Run the garden demo from the repository root:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! FORGE_PROJECT=examples/demo-project \
|
||||||
|
//! FORGE_SCENE=scenes/garden.forge.ron \
|
||||||
|
//! cargo run -p bevy_forge_runtime --example editor_live
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! `FORGE_SCENE=""` disables scene loading. With no scene (or a load failure)
|
||||||
|
//! the editor still starts with its placeholder camera and empty hierarchy.
|
||||||
|
|
||||||
|
use bevy::{prelude::*, window::WindowResolution};
|
||||||
|
use bevy_forge_runtime::EditorPlugin;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
App::new()
|
||||||
|
.add_plugins(DefaultPlugins.set(WindowPlugin {
|
||||||
|
primary_window: Some(Window {
|
||||||
|
title: "Bevy Forge — M1 Core".into(),
|
||||||
|
resolution: WindowResolution::new(1440, 900).with_scale_factor_override(1.0),
|
||||||
|
..default()
|
||||||
|
}),
|
||||||
|
..default()
|
||||||
|
}))
|
||||||
|
.add_plugins(EditorPlugin)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
@@ -20,7 +20,8 @@ use bevy::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
plugin::{editor_active, Selection},
|
history::{History, TransformEdit},
|
||||||
|
plugin::{editor_active, EditorState, Selection},
|
||||||
ui::EditorLayout,
|
ui::EditorLayout,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -104,6 +105,9 @@ pub struct GizmoDrag {
|
|||||||
pub axis: Option<GizmoAxis>,
|
pub axis: Option<GizmoAxis>,
|
||||||
/// `Transform.translation` of the entity when the drag began (parent-local).
|
/// `Transform.translation` of the entity when the drag began (parent-local).
|
||||||
pub start_translation: Vec3,
|
pub start_translation: Vec3,
|
||||||
|
/// Full `Transform` of the entity when the drag began, used to record one
|
||||||
|
/// undo entry on release. `None` outside a drag.
|
||||||
|
pub start_transform: Option<Transform>,
|
||||||
/// First ray/plane hit in world space.
|
/// First ray/plane hit in world space.
|
||||||
pub start_world_hit: Vec3,
|
pub start_world_hit: Vec3,
|
||||||
/// World-space gizmo origin when the drag began.
|
/// World-space gizmo origin when the drag began.
|
||||||
@@ -395,6 +399,7 @@ fn begin_gizmo_drag(
|
|||||||
entity: Some(entity),
|
entity: Some(entity),
|
||||||
axis: Some(axis),
|
axis: Some(axis),
|
||||||
start_translation: target_transform.translation,
|
start_translation: target_transform.translation,
|
||||||
|
start_transform: Some(*target_transform),
|
||||||
start_world_hit,
|
start_world_hit,
|
||||||
origin,
|
origin,
|
||||||
axis_world,
|
axis_world,
|
||||||
@@ -417,7 +422,12 @@ fn update_gizmo_drag(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if !mouse.pressed(MouseButton::Left) {
|
if !mouse.pressed(MouseButton::Left) {
|
||||||
|
// 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();
|
drag.clear();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let (Some(entity), Some(_axis)) = (drag.entity, drag.axis) else {
|
let (Some(entity), Some(_axis)) = (drag.entity, drag.axis) else {
|
||||||
@@ -461,11 +471,47 @@ fn update_gizmo_drag(
|
|||||||
transform.translation = translation;
|
transform.translation = translation;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ends the drag on left release. Runs even when the editor is disabled.
|
/// Builds the single history edit for a finished gizmo drag.
|
||||||
fn end_gizmo_drag(mouse: Res<ButtonInput<MouseButton>>, mut drag: ResMut<GizmoDrag>) {
|
///
|
||||||
if drag.active && mouse.just_released(MouseButton::Left) {
|
/// The translate gizmo only edits `translation`, so a press/release without
|
||||||
drag.clear();
|
/// movement records nothing; a changed drag records one `before`/`after` edit.
|
||||||
|
fn finished_drag_edit(
|
||||||
|
entity: Entity,
|
||||||
|
before: Transform,
|
||||||
|
after: Transform,
|
||||||
|
) -> Option<TransformEdit> {
|
||||||
|
(before.translation != after.translation).then_some(TransformEdit {
|
||||||
|
entity,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ends the drag on left release and records one transform edit if the entity
|
||||||
|
/// actually moved. Runs even when the editor is disabled (the drag is always
|
||||||
|
/// cleaned up, but history is only recorded while the editor is enabled).
|
||||||
|
fn end_gizmo_drag(
|
||||||
|
mouse: Res<ButtonInput<MouseButton>>,
|
||||||
|
state: Res<EditorState>,
|
||||||
|
mut history: ResMut<History>,
|
||||||
|
transforms: Query<&Transform>,
|
||||||
|
mut drag: ResMut<GizmoDrag>,
|
||||||
|
) {
|
||||||
|
if !drag.active || !mouse.just_released(MouseButton::Left) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.enabled {
|
||||||
|
if let (Some(entity), Some(before)) = (drag.entity, drag.start_transform) {
|
||||||
|
if let Ok(after) = transforms.get(entity) {
|
||||||
|
if let Some(edit) = finished_drag_edit(entity, before, *after) {
|
||||||
|
history.push(edit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drag.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads the parent's global rotation for a selected entity, or identity.
|
/// Reads the parent's global rotation for a selected entity, or identity.
|
||||||
@@ -724,4 +770,30 @@ mod tests {
|
|||||||
assert_vec3_approx(rebuilt.translation, transform.translation);
|
assert_vec3_approx(rebuilt.translation, transform.translation);
|
||||||
assert_vec3_approx(*rebuilt.forward(), *transform.forward());
|
assert_vec3_approx(*rebuilt.forward(), *transform.forward());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drag_state_starts_without_a_start_transform() {
|
||||||
|
let drag = GizmoDrag::default();
|
||||||
|
assert!(!drag.active);
|
||||||
|
assert!(drag.start_transform.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn finished_drag_edit_only_records_translation_changes() {
|
||||||
|
let entity = Entity::PLACEHOLDER;
|
||||||
|
let before = Transform::from_xyz(1.0, 2.0, 3.0);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
finished_drag_edit(entity, before, before).is_none(),
|
||||||
|
"a press without movement must not record an edit"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut moved = before;
|
||||||
|
moved.translation.x += 0.5;
|
||||||
|
let recorded = finished_drag_edit(entity, before, moved)
|
||||||
|
.expect("a drag that changed translation records one edit");
|
||||||
|
assert_eq!(recorded.entity, entity);
|
||||||
|
assert_eq!(recorded.before, before);
|
||||||
|
assert_eq!(recorded.after, moved);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,11 +11,13 @@ use bevy::{
|
|||||||
reflect::{enums::VariantType, PartialReflect, ReflectMut, ReflectRef},
|
reflect::{enums::VariantType, PartialReflect, ReflectMut, ReflectRef},
|
||||||
text::FontSize,
|
text::FontSize,
|
||||||
ui_widgets::{
|
ui_widgets::{
|
||||||
Slider, SliderRange, SliderStep, SliderThumb, SliderValue, TrackClick, ValueChange,
|
Slider, SliderDragState, SliderRange, SliderStep, SliderThumb, SliderValue, TrackClick,
|
||||||
|
ValueChange,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
history::{History, TransformEdit},
|
||||||
plugin::{editor_active, Selection},
|
plugin::{editor_active, Selection},
|
||||||
ui::{InspectorPanel, UiTheme},
|
ui::{InspectorPanel, UiTheme},
|
||||||
};
|
};
|
||||||
@@ -228,20 +230,84 @@ fn rebuild_inspector(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Applies a slider edit to the selected entity's `Transform` through reflection.
|
/// Pending pre-edit `Transform` for one inspector slider.
|
||||||
|
///
|
||||||
|
/// The first event of a drag has `is_final == false`; the last one has
|
||||||
|
/// `is_final == true`. A click/snap may deliver a single `is_final == true`
|
||||||
|
/// event, in which case the current transform is captured before applying it.
|
||||||
|
///
|
||||||
|
/// Bevy UI's `TrackClick::Snap` currently reports a plain track click only as a
|
||||||
|
/// non-final event, so [`on_slider_pointer_release`] finalizes that case when
|
||||||
|
/// the pointer is released without a drag. A real drag ends with a final
|
||||||
|
/// `ValueChange` from the widget's `PointerDragEnd` handler instead.
|
||||||
|
///
|
||||||
|
/// The pending state is a component on the slider entity, so selection changes
|
||||||
|
/// that rebuild the panel also discard any half-finished interaction.
|
||||||
|
#[derive(Component, Default)]
|
||||||
|
struct InspectorPendingEdit {
|
||||||
|
before: Option<Transform>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InspectorPendingEdit {
|
||||||
|
/// Returns the transform to record as `before`, remembering it for the rest
|
||||||
|
/// of the interaction.
|
||||||
|
fn capture_before(&mut self, current: Transform) -> Transform {
|
||||||
|
*self.before.get_or_insert(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finishes the interaction after the value was applied: returns one edit
|
||||||
|
/// only on the final event and only when the transform actually changed.
|
||||||
|
fn finish(
|
||||||
|
&mut self,
|
||||||
|
entity: Entity,
|
||||||
|
before: Transform,
|
||||||
|
after: Transform,
|
||||||
|
is_final: bool,
|
||||||
|
) -> Option<TransformEdit> {
|
||||||
|
if !is_final {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.before = None;
|
||||||
|
(before != after).then_some(TransformEdit {
|
||||||
|
entity,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finalizes a click that the widget reported without a final event.
|
||||||
|
///
|
||||||
|
/// Returns one edit when a pending interaction exists and the target
|
||||||
|
/// transform changed since it started.
|
||||||
|
fn finish_click(&mut self, entity: Entity, current: Transform) -> Option<TransformEdit> {
|
||||||
|
let before = self.before.take()?;
|
||||||
|
(before != current).then_some(TransformEdit {
|
||||||
|
entity,
|
||||||
|
before,
|
||||||
|
after: current,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies a slider edit to the selected entity's `Transform` through reflection
|
||||||
|
/// and records the completed interaction in the transform [`History`].
|
||||||
fn on_slider_value_change(
|
fn on_slider_value_change(
|
||||||
change: On<ValueChange<f32>>,
|
change: On<ValueChange<f32>>,
|
||||||
sliders: Query<&InspectorSlider>,
|
mut sliders: Query<(&InspectorSlider, &mut InspectorPendingEdit)>,
|
||||||
mut transforms: Query<&mut Transform>,
|
mut transforms: Query<&mut Transform>,
|
||||||
|
mut history: ResMut<History>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
let Ok(slider) = sliders.get(change.source) else {
|
let Ok((slider, mut pending)) = sliders.get_mut(change.source) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Ok(mut transform) = transforms.get_mut(slider.entity) else {
|
let Ok(mut transform) = transforms.get_mut(slider.entity) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Capture before applying the value: for a drag this happens on the first
|
||||||
|
// non-final event, for a click/snap on the single final event.
|
||||||
|
let before = pending.capture_before(*transform);
|
||||||
let path = ["translation", slider.axis.name()];
|
let path = ["translation", slider.axis.name()];
|
||||||
if apply_f32_path(transform.as_reflect_mut(), &path, change.value) {
|
if apply_f32_path(transform.as_reflect_mut(), &path, change.value) {
|
||||||
// Keep the widget's own state in step with the edit; the gizmo-facing
|
// Keep the widget's own state in step with the edit; the gizmo-facing
|
||||||
@@ -250,6 +316,43 @@ fn on_slider_value_change(
|
|||||||
.entity(change.source)
|
.entity(change.source)
|
||||||
.insert(SliderValue(change.value));
|
.insert(SliderValue(change.value));
|
||||||
}
|
}
|
||||||
|
let after = *transform;
|
||||||
|
|
||||||
|
if let Some(edit) = pending.finish(slider.entity, before, after, change.is_final) {
|
||||||
|
history.push(edit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finalizes a click that produced no final `ValueChange` event.
|
||||||
|
///
|
||||||
|
/// While a slider drag is running the widget emits its own final event from the
|
||||||
|
/// drag-end handler, so this observer must not record a second edit; it only
|
||||||
|
/// handles releases where no drag is in progress.
|
||||||
|
fn on_slider_pointer_release(
|
||||||
|
release: On<PointerRelease>,
|
||||||
|
mut sliders: Query<(
|
||||||
|
&InspectorSlider,
|
||||||
|
&mut InspectorPendingEdit,
|
||||||
|
Option<&SliderDragState>,
|
||||||
|
)>,
|
||||||
|
transforms: Query<&Transform>,
|
||||||
|
mut history: ResMut<History>,
|
||||||
|
) {
|
||||||
|
if release.button != PointerButton::Primary {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok((slider, mut pending, drag)) = sliders.get_mut(release.entity) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if drag.is_some_and(|drag| drag.dragging) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(transform) = transforms.get(slider.entity) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(edit) = pending.finish_click(slider.entity, *transform) {
|
||||||
|
history.push(edit);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Copies the selected entity's `Transform.translation` into slider state and labels.
|
/// Copies the selected entity's `Transform.translation` into slider state and labels.
|
||||||
@@ -394,8 +497,10 @@ fn spawn_slider_row(
|
|||||||
SliderRange::new(SLIDER_MIN, SLIDER_MAX),
|
SliderRange::new(SLIDER_MIN, SLIDER_MAX),
|
||||||
SliderStep(SLIDER_STEP),
|
SliderStep(SLIDER_STEP),
|
||||||
InspectorSlider { entity, axis },
|
InspectorSlider { entity, axis },
|
||||||
|
InspectorPendingEdit::default(),
|
||||||
))
|
))
|
||||||
.observe(on_slider_value_change)
|
.observe(on_slider_value_change)
|
||||||
|
.observe(on_slider_pointer_release)
|
||||||
.with_children(|slider| {
|
.with_children(|slider| {
|
||||||
// Background rail.
|
// Background rail.
|
||||||
slider.spawn((
|
slider.spawn((
|
||||||
@@ -781,4 +886,120 @@ mod tests {
|
|||||||
assert!(!apply_f32_path(&mut transform, &["nonexistent"], 1.0));
|
assert!(!apply_f32_path(&mut transform, &["nonexistent"], 1.0));
|
||||||
assert_eq!(transform, Transform::default());
|
assert_eq!(transform, Transform::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slider_tracker_records_one_edit_for_a_whole_drag() {
|
||||||
|
let entity = Entity::PLACEHOLDER;
|
||||||
|
let mut tracker = InspectorPendingEdit::default();
|
||||||
|
let start = Transform::from_xyz(0.0, 0.0, 0.0);
|
||||||
|
let middle = Transform::from_xyz(1.0, 0.0, 0.0);
|
||||||
|
let end = Transform::from_xyz(2.0, 0.0, 0.0);
|
||||||
|
|
||||||
|
// First non-final event captures the pre-drag transform.
|
||||||
|
let before = tracker.capture_before(start);
|
||||||
|
assert!(tracker.finish(entity, before, middle, false).is_none());
|
||||||
|
|
||||||
|
// Later non-final events reuse the original `before`.
|
||||||
|
let before = tracker.capture_before(middle);
|
||||||
|
assert_eq!(before, start);
|
||||||
|
assert!(tracker.finish(entity, before, end, false).is_none());
|
||||||
|
|
||||||
|
// The final event records exactly one edit and resets the tracker.
|
||||||
|
let before = tracker.capture_before(end);
|
||||||
|
assert_eq!(before, start);
|
||||||
|
let recorded = tracker
|
||||||
|
.finish(entity, before, end, true)
|
||||||
|
.expect("a changed drag should record one edit");
|
||||||
|
assert_eq!(recorded.entity, entity);
|
||||||
|
assert_eq!(recorded.before, start);
|
||||||
|
assert_eq!(recorded.after, end);
|
||||||
|
assert!(tracker.before.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slider_tracker_records_a_single_final_click_edit() {
|
||||||
|
let entity = Entity::PLACEHOLDER;
|
||||||
|
let mut tracker = InspectorPendingEdit::default();
|
||||||
|
let before = Transform::from_xyz(0.0, 0.0, 0.0);
|
||||||
|
let after = Transform::from_xyz(3.5, 0.0, 0.0);
|
||||||
|
|
||||||
|
let captured = tracker.capture_before(before);
|
||||||
|
let recorded = tracker
|
||||||
|
.finish(entity, captured, after, true)
|
||||||
|
.expect("a snap/click with is_final=true should record one edit");
|
||||||
|
|
||||||
|
assert_eq!(recorded.before, before);
|
||||||
|
assert_eq!(recorded.after, after);
|
||||||
|
assert!(tracker.before.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slider_tracker_does_not_record_no_op_edits() {
|
||||||
|
let entity = Entity::PLACEHOLDER;
|
||||||
|
let mut tracker = InspectorPendingEdit::default();
|
||||||
|
let unchanged = Transform::from_xyz(1.0, 2.0, 3.0);
|
||||||
|
|
||||||
|
let captured = tracker.capture_before(unchanged);
|
||||||
|
assert!(tracker.finish(entity, captured, unchanged, true).is_none());
|
||||||
|
assert!(tracker.before.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn slider_tracker_pushes_only_one_entry_into_history() {
|
||||||
|
let entity = Entity::PLACEHOLDER;
|
||||||
|
let mut tracker = InspectorPendingEdit::default();
|
||||||
|
let mut history = History::default();
|
||||||
|
let start = Transform::from_xyz(0.0, 0.0, 0.0);
|
||||||
|
let middle = Transform::from_xyz(1.0, 0.0, 0.0);
|
||||||
|
let end = Transform::from_xyz(2.0, 0.0, 0.0);
|
||||||
|
|
||||||
|
for (current, after, is_final) in [
|
||||||
|
(start, middle, false),
|
||||||
|
(middle, end, false),
|
||||||
|
(end, end, true),
|
||||||
|
] {
|
||||||
|
let before = tracker.capture_before(current);
|
||||||
|
if let Some(edit) = tracker.finish(entity, before, after, is_final) {
|
||||||
|
history.push(edit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(history.undo().expect("one recorded edit").after, end);
|
||||||
|
assert!(history.undo().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pending_click_is_finalized_on_pointer_release() {
|
||||||
|
let entity = Entity::PLACEHOLDER;
|
||||||
|
let mut pending = InspectorPendingEdit::default();
|
||||||
|
let before = Transform::from_xyz(0.0, 0.0, 0.0);
|
||||||
|
let after = Transform::from_xyz(1.25, 0.0, 0.0);
|
||||||
|
|
||||||
|
// A `TrackClick::Snap` click arrives as a non-final event and updates
|
||||||
|
// the transform; no final event follows, so the release finalizes it.
|
||||||
|
let captured = pending.capture_before(before);
|
||||||
|
assert!(pending.finish(entity, captured, after, false).is_none());
|
||||||
|
|
||||||
|
let recorded = pending
|
||||||
|
.finish_click(entity, after)
|
||||||
|
.expect("click release should record the pending edit");
|
||||||
|
assert_eq!(recorded.before, before);
|
||||||
|
assert_eq!(recorded.after, after);
|
||||||
|
assert!(pending.finish_click(entity, after).is_none());
|
||||||
|
|
||||||
|
// A no-op click records nothing.
|
||||||
|
let captured = pending.capture_before(after);
|
||||||
|
assert!(pending.finish(entity, captured, after, false).is_none());
|
||||||
|
assert!(pending.finish_click(entity, after).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn click_release_without_a_pending_edit_is_a_no_op() {
|
||||||
|
let entity = Entity::PLACEHOLDER;
|
||||||
|
let mut pending = InspectorPendingEdit::default();
|
||||||
|
|
||||||
|
assert!(pending
|
||||||
|
.finish_click(entity, Transform::from_xyz(1.0, 0.0, 0.0))
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
//! Bevy Forge live editor runtime (v2 M0 spike).
|
//! Bevy Forge live editor runtime (v2 live editor, M1 core).
|
||||||
//!
|
//!
|
||||||
//! [`EditorPlugin`] installs the editor resources, the fixed Bevy UI shell
|
//! [`EditorPlugin`] installs the editor resources, the fixed Bevy UI shell
|
||||||
//! (toolbar, hierarchy panel, inspector panel, status bar) and the module plugins
|
//! (toolbar, hierarchy panel, inspector panel, status bar), the scene bridge that
|
||||||
//! that provide hierarchy, inspector, gizmo and screenshot behavior.
|
//! loads a `.forge.ron` scene into the live ECS and the module plugins that
|
||||||
|
//! provide hierarchy, inspector, gizmo and screenshot behavior.
|
||||||
//!
|
//!
|
||||||
//! The plugin is designed to be added after `DefaultPlugins`:
|
//! The plugin is designed to be added after `DefaultPlugins`:
|
||||||
//!
|
//!
|
||||||
@@ -15,16 +16,22 @@
|
|||||||
|
|
||||||
pub mod gizmo;
|
pub mod gizmo;
|
||||||
pub mod hierarchy;
|
pub mod hierarchy;
|
||||||
|
pub mod history;
|
||||||
pub mod inspector;
|
pub mod inspector;
|
||||||
pub mod plugin;
|
pub mod plugin;
|
||||||
|
pub mod scene;
|
||||||
pub mod screenshot;
|
pub mod screenshot;
|
||||||
|
pub mod shortcuts;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
|
|
||||||
pub use gizmo::GizmoPlugin;
|
pub use gizmo::GizmoPlugin;
|
||||||
pub use hierarchy::HierarchyPlugin;
|
pub use hierarchy::HierarchyPlugin;
|
||||||
|
pub use history::HistoryPlugin;
|
||||||
pub use inspector::InspectorPlugin;
|
pub use inspector::InspectorPlugin;
|
||||||
pub use plugin::{editor_active, EditorPlugin, EditorState, InspectorSource, Selection};
|
pub use plugin::{editor_active, EditorPlugin, EditorState, InspectorSource, Selection};
|
||||||
|
pub use scene::{SceneConfig, SceneDocument, ScenePlugin};
|
||||||
pub use screenshot::ScreenshotPlugin;
|
pub use screenshot::ScreenshotPlugin;
|
||||||
|
pub use shortcuts::ShortcutsPlugin;
|
||||||
pub use ui::{
|
pub use ui::{
|
||||||
EditorLayout, EditorRoot, HierarchyPanel, InspectorPanel, StatusBar, Toolbar, UiTheme,
|
EditorLayout, EditorRoot, HierarchyPanel, InspectorPanel, StatusBar, Toolbar, UiTheme,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
gizmo::GizmoPlugin, hierarchy::HierarchyPlugin, inspector::InspectorPlugin,
|
gizmo::GizmoPlugin, hierarchy::HierarchyPlugin, history::HistoryPlugin,
|
||||||
screenshot::ScreenshotPlugin, ui::shell::EditorUiPlugin,
|
inspector::InspectorPlugin, scene::ScenePlugin, screenshot::ScreenshotPlugin,
|
||||||
|
shortcuts::ShortcutsPlugin, ui::shell::EditorUiPlugin,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Installs the Bevy Forge live editor on top of `DefaultPlugins`.
|
/// Installs the Bevy Forge live editor on top of `DefaultPlugins`.
|
||||||
@@ -20,6 +21,9 @@ impl Plugin for EditorPlugin {
|
|||||||
.init_resource::<Selection>()
|
.init_resource::<Selection>()
|
||||||
.add_plugins((
|
.add_plugins((
|
||||||
EditorUiPlugin,
|
EditorUiPlugin,
|
||||||
|
ScenePlugin,
|
||||||
|
ShortcutsPlugin,
|
||||||
|
HistoryPlugin,
|
||||||
HierarchyPlugin,
|
HierarchyPlugin,
|
||||||
InspectorPlugin,
|
InspectorPlugin,
|
||||||
GizmoPlugin,
|
GizmoPlugin,
|
||||||
|
|||||||
@@ -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,660 @@
|
|||||||
|
//! Editor keyboard shortcuts.
|
||||||
|
//!
|
||||||
|
//! M1 hotkeys:
|
||||||
|
//!
|
||||||
|
//! | Key | Action |
|
||||||
|
//! |---|---|
|
||||||
|
//! | `Ctrl+S` | sync the live ECS into the document and save the scene |
|
||||||
|
//! | `F` | move the [`EditorCamera`] focus to the selected entity |
|
||||||
|
//! | `Delete` | delete the selected subtree from the ECS and the document |
|
||||||
|
//! | `Ctrl+D` | duplicate the selected subtree and select the copy |
|
||||||
|
//!
|
||||||
|
//! Delete and duplicate are structural edits and are intentionally **not
|
||||||
|
//! undoable in M1**; only Transform edits from the inspector/gizmo go through
|
||||||
|
//! [`crate::history`]. All shortcuts are ignored while
|
||||||
|
//! [`crate::EditorState::enabled`] is false. Actions that need a selection are
|
||||||
|
//! also ignored when nothing is selected.
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use bevy_forge_core::EntityId;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
gizmo::{camera_transform, EditorCamera},
|
||||||
|
plugin::{editor_active, Selection},
|
||||||
|
scene::{
|
||||||
|
duplicate_subtree, remove_subtree_entities, save_scene, sync_doc_from_ecs, SceneDocument,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Installs the editor hotkeys.
|
||||||
|
pub struct ShortcutsPlugin;
|
||||||
|
|
||||||
|
impl Plugin for ShortcutsPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
save_scene_shortcut,
|
||||||
|
focus_selected,
|
||||||
|
delete_selected,
|
||||||
|
duplicate_selected,
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
.run_if(editor_active),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Ctrl+S`: sync `Name`/`Transform`/`Visibility` then write the scene.
|
||||||
|
fn save_scene_shortcut(
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
document: Option<ResMut<SceneDocument>>,
|
||||||
|
names: Query<&Name>,
|
||||||
|
transforms: Query<&Transform>,
|
||||||
|
visibilities: Query<&Visibility>,
|
||||||
|
) {
|
||||||
|
if !ctrl_held(&keys) || !keys.just_pressed(KeyCode::KeyS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(mut document) = document else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let synced = sync_doc_from_ecs(&mut document, &names, &transforms, &visibilities);
|
||||||
|
match save_scene(&document) {
|
||||||
|
Ok(path) => info!(
|
||||||
|
"shortcut: saved {} ({synced} entities synced)",
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
|
Err(error) => warn!("shortcut: save failed: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `F` (without any modifier): focus the editor camera on the selection.
|
||||||
|
fn focus_selected(
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
selection: Res<Selection>,
|
||||||
|
globals: Query<&GlobalTransform>,
|
||||||
|
mut cameras: Query<(&mut EditorCamera, &mut Transform)>,
|
||||||
|
) {
|
||||||
|
if !keys.just_pressed(KeyCode::KeyF) || any_modifier_held(&keys) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(entity) = selection.0 else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(global) = globals.get(entity) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok((mut camera, mut transform)) = cameras.single_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
camera.focus = global.translation();
|
||||||
|
// Keep the orbit model consistent so the camera actually moves this frame;
|
||||||
|
// yaw, pitch and distance stay untouched.
|
||||||
|
*transform = camera_transform(&camera);
|
||||||
|
info!(
|
||||||
|
"shortcut: focus {entity} at ({}, {}, {})",
|
||||||
|
camera.focus.x, camera.focus.y, camera.focus.z
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Delete`: remove the selected subtree from the document, the entity map and
|
||||||
|
/// the ECS, then clear the selection. Not undoable in M1.
|
||||||
|
fn delete_selected(
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
document: Option<ResMut<SceneDocument>>,
|
||||||
|
mut selection: ResMut<Selection>,
|
||||||
|
cameras: Query<(), Or<(With<EditorCamera>, With<Camera3d>)>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
if !keys.just_pressed(KeyCode::Delete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(entity) = selection.0 else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(mut document) = document else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(id) = tracked_id(&document, entity) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if subtree_contains_camera(&document, id, &cameras) {
|
||||||
|
warn!("shortcut: refusing to delete camera subtree {id} (editor camera must stay alive)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let removed = remove_subtree_entities(&mut document, id);
|
||||||
|
// `remove_subtree_entities` returns parent-first document order, so despawn
|
||||||
|
// in reverse to take the children down before their parents.
|
||||||
|
for removed_entity in removed.iter().rev() {
|
||||||
|
commands.entity(*removed_entity).despawn();
|
||||||
|
}
|
||||||
|
selection.0 = None;
|
||||||
|
info!("shortcut: deleted {id} ({} entities)", removed.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Ctrl+D`: deep-copy the selected subtree in the document and the ECS, then
|
||||||
|
/// select the copy. Not undoable in M1.
|
||||||
|
fn duplicate_selected(
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
document: Option<ResMut<SceneDocument>>,
|
||||||
|
mut selection: ResMut<Selection>,
|
||||||
|
cameras: Query<(), Or<(With<EditorCamera>, With<Camera3d>)>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut meshes: ResMut<Assets<Mesh>>,
|
||||||
|
mut materials: ResMut<Assets<StandardMaterial>>,
|
||||||
|
) {
|
||||||
|
if !ctrl_held(&keys) || !keys.just_pressed(KeyCode::KeyD) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(entity) = selection.0 else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(mut document) = document else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(id) = tracked_id(&document, entity) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if subtree_contains_camera(&document, id, &cameras) {
|
||||||
|
warn!("shortcut: refusing to duplicate camera subtree {id} (only one editor camera)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(new_root) = duplicate_subtree(
|
||||||
|
&mut document,
|
||||||
|
id,
|
||||||
|
&mut commands,
|
||||||
|
&mut meshes,
|
||||||
|
&mut materials,
|
||||||
|
) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(new_entity) = document.entities.get(&new_root).copied() {
|
||||||
|
selection.0 = Some(new_entity);
|
||||||
|
}
|
||||||
|
info!("shortcut: duplicated {id} -> {new_root}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `id` or one of its descendants is a camera entity.
|
||||||
|
///
|
||||||
|
/// M1 refuses to delete or duplicate a camera subtree: the editor camera is
|
||||||
|
/// adopted from the scene, so removing it breaks gizmo/camera behavior, and a
|
||||||
|
/// duplicated `Camera3d` would leave two active cameras.
|
||||||
|
fn subtree_contains_camera(
|
||||||
|
document: &SceneDocument,
|
||||||
|
id: EntityId,
|
||||||
|
cameras: &Query<(), Or<(With<EditorCamera>, With<Camera3d>)>>,
|
||||||
|
) -> bool {
|
||||||
|
document.entities.iter().any(|(other_id, entity)| {
|
||||||
|
(*other_id == id || document.doc.is_ancestor(id, *other_id)) && cameras.contains(*entity)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ctrl_held(keys: &ButtonInput<KeyCode>) -> bool {
|
||||||
|
keys.any_pressed([KeyCode::ControlLeft, KeyCode::ControlRight])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn any_modifier_held(keys: &ButtonInput<KeyCode>) -> bool {
|
||||||
|
keys.any_pressed([
|
||||||
|
KeyCode::ControlLeft,
|
||||||
|
KeyCode::ControlRight,
|
||||||
|
KeyCode::AltLeft,
|
||||||
|
KeyCode::AltRight,
|
||||||
|
KeyCode::ShiftLeft,
|
||||||
|
KeyCode::ShiftRight,
|
||||||
|
KeyCode::SuperLeft,
|
||||||
|
KeyCode::SuperRight,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds the document id mapped to a live ECS entity.
|
||||||
|
fn tracked_id(document: &SceneDocument, entity: Entity) -> Option<EntityId> {
|
||||||
|
document
|
||||||
|
.entities
|
||||||
|
.iter()
|
||||||
|
.find_map(|(id, candidate)| (*candidate == entity).then_some(*id))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
use bevy::math::EulerRot;
|
||||||
|
use bevy_forge_core::{
|
||||||
|
ComponentData, CustomComponent, CustomField, Mesh3dData, Project, SceneDoc,
|
||||||
|
Transform as CoreTransform, VisibilityMode,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::{plugin::EditorState, scene::SceneDocument};
|
||||||
|
|
||||||
|
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
/// A throwaway Bevy project on disk, removed on drop.
|
||||||
|
struct TempProject {
|
||||||
|
root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TempProject {
|
||||||
|
fn new(label: &str) -> Self {
|
||||||
|
let unique = NEXT_TEMP.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let root = std::env::temp_dir()
|
||||||
|
.join(format!("forge-t12-{label}-{}-{unique}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(root.join("scenes")).expect("create temp scenes dir");
|
||||||
|
std::fs::write(
|
||||||
|
root.join("Cargo.toml"),
|
||||||
|
"[package]\nname = \"forge-t12-test\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nbevy = \"0.20\"\n",
|
||||||
|
)
|
||||||
|
.expect("write temp Cargo.toml");
|
||||||
|
Self { root }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open(&self) -> Project {
|
||||||
|
Project::open(&self.root).expect("temp project must open")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manifest(&self) -> String {
|
||||||
|
std::fs::read_to_string(self.root.join("Cargo.toml")).expect("temp manifest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TempProject {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dummy_project() -> Project {
|
||||||
|
Project {
|
||||||
|
root: PathBuf::from("/tmp/forge-t12"),
|
||||||
|
name: "forge-t12-test".into(),
|
||||||
|
assets_dir: PathBuf::from("/tmp/forge-t12"),
|
||||||
|
bevy_version: Some("0.20".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scene_document(
|
||||||
|
project: Project,
|
||||||
|
doc: SceneDoc,
|
||||||
|
entities: HashMap<EntityId, Entity>,
|
||||||
|
) -> SceneDocument {
|
||||||
|
SceneDocument {
|
||||||
|
doc,
|
||||||
|
project,
|
||||||
|
scene_path: PathBuf::from("scenes/test.forge.ron"),
|
||||||
|
save_path: PathBuf::from("scenes/out.forge.ron"),
|
||||||
|
entities,
|
||||||
|
loaded: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An app with the resources the real `DefaultPlugins` app provides for the
|
||||||
|
/// shortcut systems, minus the window/renderer.
|
||||||
|
fn headless_app() -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.init_resource::<Assets<Mesh>>()
|
||||||
|
.init_resource::<Assets<StandardMaterial>>()
|
||||||
|
.init_resource::<ButtonInput<KeyCode>>()
|
||||||
|
.insert_resource(EditorState::default())
|
||||||
|
.insert_resource(Selection(None));
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn press(app: &mut App, keys: impl IntoIterator<Item = KeyCode>) {
|
||||||
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||||
|
input.clear();
|
||||||
|
for key in keys {
|
||||||
|
input.press(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_named(world: &mut World, name: &'static str, parent: Option<Entity>) -> Entity {
|
||||||
|
let mut entity = world.spawn((
|
||||||
|
Name::new(name),
|
||||||
|
Transform::default(),
|
||||||
|
GlobalTransform::default(),
|
||||||
|
));
|
||||||
|
if let Some(parent) = parent {
|
||||||
|
entity.insert(ChildOf(parent));
|
||||||
|
}
|
||||||
|
entity.id()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn custom_component() -> CustomComponent {
|
||||||
|
CustomComponent {
|
||||||
|
type_path: "forge_t12::Marker".into(),
|
||||||
|
fields: vec![CustomField {
|
||||||
|
name: "value".into(),
|
||||||
|
expr: "7".into(),
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_close(actual: f32, expected: f32) {
|
||||||
|
assert!(
|
||||||
|
(actual - expected).abs() < 1e-3,
|
||||||
|
"expected {expected}, got {actual}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shortcut_focus_sets_editor_camera_focus() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
let target = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn(GlobalTransform::from_translation(Vec3::new(2.0, 3.0, 4.0)))
|
||||||
|
.id();
|
||||||
|
let default_camera = EditorCamera::default();
|
||||||
|
let camera_entity = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((
|
||||||
|
default_camera,
|
||||||
|
Transform::default(),
|
||||||
|
GlobalTransform::default(),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
app.world_mut().resource_mut::<Selection>().0 = Some(target);
|
||||||
|
app.add_plugins(ShortcutsPlugin);
|
||||||
|
|
||||||
|
press(&mut app, [KeyCode::KeyF]);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let camera = *app.world().get::<EditorCamera>(camera_entity).unwrap();
|
||||||
|
assert_eq!(camera.focus, Vec3::new(2.0, 3.0, 4.0));
|
||||||
|
// Yaw/pitch/distance stay untouched by focus.
|
||||||
|
assert_eq!(camera.yaw, default_camera.yaw);
|
||||||
|
assert_eq!(camera.pitch, default_camera.pitch);
|
||||||
|
assert_eq!(camera.distance, default_camera.distance);
|
||||||
|
let transform = *app.world().get::<Transform>(camera_entity).unwrap();
|
||||||
|
assert!(transform
|
||||||
|
.translation
|
||||||
|
.abs_diff_eq(camera_transform(&camera).translation, 1e-4));
|
||||||
|
|
||||||
|
// `F` must not fire while a modifier is held.
|
||||||
|
app.world_mut().resource_mut::<Selection>().0 = Some(target);
|
||||||
|
press(&mut app, [KeyCode::ControlLeft, KeyCode::KeyF]);
|
||||||
|
app.update();
|
||||||
|
let camera = *app.world().get::<EditorCamera>(camera_entity).unwrap();
|
||||||
|
assert_eq!(camera.focus, Vec3::new(2.0, 3.0, 4.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shortcut_delete_removes_selected_subtree_from_doc_and_map() {
|
||||||
|
let mut doc = SceneDoc::new("Delete");
|
||||||
|
let root = doc.add_entity("Root", None);
|
||||||
|
let child = doc.add_entity("Child", Some(root));
|
||||||
|
let sibling = doc.add_entity("Sibling", None);
|
||||||
|
|
||||||
|
let mut app = headless_app();
|
||||||
|
let (root_ecs, child_ecs, sibling_ecs) = {
|
||||||
|
let world = app.world_mut();
|
||||||
|
let root_ecs = spawn_named(world, "Root", None);
|
||||||
|
let child_ecs = spawn_named(world, "Child", Some(root_ecs));
|
||||||
|
let sibling_ecs = spawn_named(world, "Sibling", None);
|
||||||
|
(root_ecs, child_ecs, sibling_ecs)
|
||||||
|
};
|
||||||
|
let mut entities = HashMap::new();
|
||||||
|
entities.insert(root, root_ecs);
|
||||||
|
entities.insert(child, child_ecs);
|
||||||
|
entities.insert(sibling, sibling_ecs);
|
||||||
|
|
||||||
|
app.insert_resource(scene_document(dummy_project(), doc, entities));
|
||||||
|
app.insert_resource(Selection(Some(root_ecs)));
|
||||||
|
app.add_plugins(ShortcutsPlugin);
|
||||||
|
|
||||||
|
press(&mut app, [KeyCode::Delete]);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let document = app.world().resource::<SceneDocument>();
|
||||||
|
assert!(document.doc.entity(root).is_none());
|
||||||
|
assert!(document.doc.entity(child).is_none());
|
||||||
|
assert!(document.doc.entity(sibling).is_some());
|
||||||
|
assert_eq!(document.entities.len(), 1);
|
||||||
|
assert_eq!(document.entities.get(&sibling), Some(&sibling_ecs));
|
||||||
|
assert_eq!(app.world().resource::<Selection>().0, None);
|
||||||
|
|
||||||
|
let world = app.world();
|
||||||
|
assert!(world.get::<Name>(root_ecs).is_none());
|
||||||
|
assert!(world.get::<Name>(child_ecs).is_none());
|
||||||
|
assert!(world.get::<Name>(sibling_ecs).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shortcut_duplicate_deep_copies_subtree_in_doc_and_map_and_selects_copy() {
|
||||||
|
let mut doc = SceneDoc::new("Duplicate");
|
||||||
|
let root = doc.add_entity("Root", None);
|
||||||
|
doc.add_component(root, ComponentData::Mesh3d(Mesh3dData::default()));
|
||||||
|
doc.add_component(root, ComponentData::Custom(custom_component()));
|
||||||
|
let child = doc.add_entity("Child", Some(root));
|
||||||
|
|
||||||
|
let mut app = headless_app();
|
||||||
|
let (root_ecs, child_ecs) = {
|
||||||
|
let world = app.world_mut();
|
||||||
|
let root_ecs = spawn_named(world, "Root", None);
|
||||||
|
let child_ecs = spawn_named(world, "Child", Some(root_ecs));
|
||||||
|
(root_ecs, child_ecs)
|
||||||
|
};
|
||||||
|
let mut entities = HashMap::new();
|
||||||
|
entities.insert(root, root_ecs);
|
||||||
|
entities.insert(child, child_ecs);
|
||||||
|
|
||||||
|
app.insert_resource(scene_document(dummy_project(), doc, entities));
|
||||||
|
app.insert_resource(Selection(Some(root_ecs)));
|
||||||
|
app.add_plugins(ShortcutsPlugin);
|
||||||
|
|
||||||
|
press(&mut app, [KeyCode::ControlLeft, KeyCode::KeyD]);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let document = app.world().resource::<SceneDocument>();
|
||||||
|
assert_eq!(document.doc.entities.len(), 4);
|
||||||
|
assert_eq!(document.doc.roots.len(), 2);
|
||||||
|
let new_root = document.doc.roots[1];
|
||||||
|
assert_ne!(new_root, root);
|
||||||
|
let new_child = document.doc.entity(new_root).unwrap().children[0];
|
||||||
|
assert!(document.doc.is_ancestor(new_root, new_child));
|
||||||
|
assert_eq!(document.doc.entity(new_root).unwrap().name, "Root Copy");
|
||||||
|
assert_eq!(document.doc.entity(new_child).unwrap().name, "Child");
|
||||||
|
assert_eq!(document.entities.len(), 4);
|
||||||
|
let new_root_ecs = document.entities[&new_root];
|
||||||
|
let new_child_ecs = document.entities[&new_child];
|
||||||
|
assert_eq!(document.entities[&root], root_ecs);
|
||||||
|
assert_ne!(new_root_ecs, root_ecs);
|
||||||
|
assert_eq!(app.world().resource::<Selection>().0, Some(new_root_ecs));
|
||||||
|
|
||||||
|
// Deep copy keeps the custom component and the mesh component data.
|
||||||
|
let copy = document.doc.entity(new_root).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
copy.component("custom:forge_t12::Marker"),
|
||||||
|
Some(&ComponentData::Custom(custom_component()))
|
||||||
|
);
|
||||||
|
assert!(copy.component("mesh3d").is_some());
|
||||||
|
|
||||||
|
let world = app.world();
|
||||||
|
assert_eq!(
|
||||||
|
world.get::<Name>(new_root_ecs).unwrap().as_str(),
|
||||||
|
"Root Copy"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
world.get::<ChildOf>(new_child_ecs).map(ChildOf::parent),
|
||||||
|
Some(new_root_ecs)
|
||||||
|
);
|
||||||
|
assert!(world.get::<Mesh3d>(new_root_ecs).is_some());
|
||||||
|
assert!(world.get::<Name>(root_ecs).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shortcut_save_round_trips_ecs_edits_in_a_temp_project() {
|
||||||
|
let temp = TempProject::new("save");
|
||||||
|
let project = temp.open();
|
||||||
|
let manifest_before = temp.manifest();
|
||||||
|
|
||||||
|
let mut doc = SceneDoc::new("T12 Round Trip");
|
||||||
|
let root = doc.add_entity("Root", None);
|
||||||
|
doc.add_component(root, ComponentData::Mesh3d(Mesh3dData::default()));
|
||||||
|
doc.add_component(root, ComponentData::Custom(custom_component()));
|
||||||
|
doc.add_component(root, ComponentData::Visibility(VisibilityMode::Hidden));
|
||||||
|
let child = doc.add_entity("Child", Some(root));
|
||||||
|
|
||||||
|
let mut app = headless_app();
|
||||||
|
let (root_ecs, child_ecs) = {
|
||||||
|
let world = app.world_mut();
|
||||||
|
let root_transform = Transform::from_xyz(1.5, -2.0, 3.25)
|
||||||
|
.with_rotation(Quat::from_euler(
|
||||||
|
EulerRot::XYZ,
|
||||||
|
10.0f32.to_radians(),
|
||||||
|
20.0f32.to_radians(),
|
||||||
|
(-30.0f32).to_radians(),
|
||||||
|
))
|
||||||
|
.with_scale(Vec3::new(2.0, 1.0, 0.5));
|
||||||
|
let root_ecs = world
|
||||||
|
.spawn((
|
||||||
|
Name::new("Renamed Root"),
|
||||||
|
root_transform,
|
||||||
|
Visibility::Hidden,
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
let child_ecs = world
|
||||||
|
.spawn((
|
||||||
|
Name::new("Renamed Child"),
|
||||||
|
Transform::from_xyz(0.5, 1.0, -1.5),
|
||||||
|
ChildOf(root_ecs),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
(root_ecs, child_ecs)
|
||||||
|
};
|
||||||
|
let mut entities = HashMap::new();
|
||||||
|
entities.insert(root, root_ecs);
|
||||||
|
entities.insert(child, child_ecs);
|
||||||
|
|
||||||
|
app.insert_resource(scene_document(project.clone(), doc, entities));
|
||||||
|
app.insert_resource(Selection(Some(root_ecs)));
|
||||||
|
app.add_plugins(ShortcutsPlugin);
|
||||||
|
|
||||||
|
press(&mut app, [KeyCode::ControlLeft, KeyCode::KeyS]);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let save_path = temp.root.join("scenes/out.forge.ron");
|
||||||
|
assert!(save_path.is_file(), "Ctrl+S must write the scene file");
|
||||||
|
let loaded = project
|
||||||
|
.load_scene(&save_path)
|
||||||
|
.expect("saved scene must parse");
|
||||||
|
assert_eq!(loaded.name, "T12 Round Trip");
|
||||||
|
assert!(loaded.check_integrity().is_empty());
|
||||||
|
|
||||||
|
let loaded_root = loaded.entity(root).expect("root must round-trip");
|
||||||
|
assert_eq!(loaded_root.name, "Renamed Root");
|
||||||
|
assert_core_transform(
|
||||||
|
&loaded_root.transform,
|
||||||
|
Vec3::new(1.5, -2.0, 3.25),
|
||||||
|
[10.0, 20.0, -30.0],
|
||||||
|
Vec3::new(2.0, 1.0, 0.5),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
loaded_root.component("visibility"),
|
||||||
|
Some(&ComponentData::Visibility(VisibilityMode::Hidden))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
loaded_root.component("custom:forge_t12::Marker"),
|
||||||
|
Some(&ComponentData::Custom(custom_component()))
|
||||||
|
);
|
||||||
|
assert!(loaded_root.component("mesh3d").is_some());
|
||||||
|
|
||||||
|
let loaded_child = loaded.entity(child).expect("child must round-trip");
|
||||||
|
assert_eq!(loaded_child.name, "Renamed Child");
|
||||||
|
assert_core_transform(
|
||||||
|
&loaded_child.transform,
|
||||||
|
Vec3::new(0.5, 1.0, -1.5),
|
||||||
|
[0.0, 0.0, 0.0],
|
||||||
|
Vec3::ONE,
|
||||||
|
);
|
||||||
|
// No visibility component was created just because Bevy has a default
|
||||||
|
// `Visibility` value.
|
||||||
|
assert!(loaded_child.components.is_empty());
|
||||||
|
|
||||||
|
// Only the requested scene was written; the project manifest is untouched.
|
||||||
|
let mut scene_files: Vec<_> = std::fs::read_dir(temp.root.join("scenes"))
|
||||||
|
.unwrap()
|
||||||
|
.map(|entry| entry.unwrap().file_name())
|
||||||
|
.collect();
|
||||||
|
scene_files.sort();
|
||||||
|
assert_eq!(scene_files, vec![std::ffi::OsString::from("out.forge.ron")]);
|
||||||
|
assert_eq!(temp.manifest(), manifest_before);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shortcut_actions_are_ignored_when_editor_is_disabled() {
|
||||||
|
let mut doc = SceneDoc::new("Disabled");
|
||||||
|
let root = doc.add_entity("Root", None);
|
||||||
|
let mut app = headless_app();
|
||||||
|
let root_ecs = spawn_named(app.world_mut(), "Root", None);
|
||||||
|
let mut entities = HashMap::new();
|
||||||
|
entities.insert(root, root_ecs);
|
||||||
|
|
||||||
|
app.insert_resource(scene_document(dummy_project(), doc, entities));
|
||||||
|
app.insert_resource(Selection(Some(root_ecs)));
|
||||||
|
app.world_mut().resource_mut::<EditorState>().enabled = false;
|
||||||
|
app.add_plugins(ShortcutsPlugin);
|
||||||
|
|
||||||
|
press(&mut app, [KeyCode::Delete]);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert!(app
|
||||||
|
.world()
|
||||||
|
.resource::<SceneDocument>()
|
||||||
|
.doc
|
||||||
|
.entity(root)
|
||||||
|
.is_some());
|
||||||
|
assert_eq!(app.world().resource::<Selection>().0, Some(root_ecs));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_refuses_a_camera_subtree() {
|
||||||
|
let mut doc = SceneDoc::new("camera-scene");
|
||||||
|
let id = doc.add_entity("Camera", None);
|
||||||
|
assert!(doc.set_component(id, ComponentData::Camera3d));
|
||||||
|
|
||||||
|
let mut app = headless_app();
|
||||||
|
let camera = app.world_mut().spawn(Camera3d::default()).id();
|
||||||
|
let entities = HashMap::from([(id, camera)]);
|
||||||
|
app.insert_resource(scene_document(dummy_project(), doc, entities));
|
||||||
|
app.insert_resource(Selection(Some(camera)));
|
||||||
|
|
||||||
|
press(&mut app, [KeyCode::Delete]);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let document = app.world().resource::<SceneDocument>();
|
||||||
|
assert!(
|
||||||
|
document.doc.entity(id).is_some(),
|
||||||
|
"the camera node must stay in the document"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
document.entities.get(&id),
|
||||||
|
Some(&camera),
|
||||||
|
"the camera mapping must stay"
|
||||||
|
);
|
||||||
|
assert_eq!(app.world().resource::<Selection>().0, Some(camera));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_core_transform(
|
||||||
|
transform: &CoreTransform,
|
||||||
|
translation: Vec3,
|
||||||
|
rotation_degrees: [f32; 3],
|
||||||
|
scale: Vec3,
|
||||||
|
) {
|
||||||
|
assert_close(transform.translation.x, translation.x);
|
||||||
|
assert_close(transform.translation.y, translation.y);
|
||||||
|
assert_close(transform.translation.z, translation.z);
|
||||||
|
assert_close(transform.rotation.x, rotation_degrees[0]);
|
||||||
|
assert_close(transform.rotation.y, rotation_degrees[1]);
|
||||||
|
assert_close(transform.rotation.z, rotation_degrees[2]);
|
||||||
|
assert_close(transform.scale.x, scale.x);
|
||||||
|
assert_close(transform.scale.y, scale.y);
|
||||||
|
assert_close(transform.scale.z, scale.z);
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 252 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 534 KiB |
+106
@@ -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.
|
||||||
Reference in New Issue
Block a user