diff --git a/Cargo.lock b/Cargo.lock index 75a57d7..9937464 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -991,6 +991,7 @@ name = "bevy_forge_runtime" version = "0.1.0" dependencies = [ "bevy", + "bevy_forge_core", ] [[package]] diff --git a/README.md b/README.md index b0bcf5f..373dbf3 100644 --- a/README.md +++ b/README.md @@ -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 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 | 2D scene editing | 3D wireframe preview | diff --git a/crates/bevy_forge_runtime/Cargo.toml b/crates/bevy_forge_runtime/Cargo.toml index 54366f1..4da21e8 100644 --- a/crates/bevy_forge_runtime/Cargo.toml +++ b/crates/bevy_forge_runtime/Cargo.toml @@ -7,9 +7,10 @@ license.workspace = true authors.workspace = true repository.workspace = true 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] +bevy_forge_core = { path = "../bevy_forge_core" } # Frozen M0 feature set. Do not add `tonemapping_luts` (ktx2/zstd) — cameras use # `Tonemapping::AcesFitted` instead of the LUT-based default. bevy = { version = "0.20", default-features = false, features = [ diff --git a/crates/bevy_forge_runtime/examples/editor_live.rs b/crates/bevy_forge_runtime/examples/editor_live.rs new file mode 100644 index 0000000..84f7eec --- /dev/null +++ b/crates/bevy_forge_runtime/examples/editor_live.rs @@ -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(); +} diff --git a/crates/bevy_forge_runtime/src/gizmo.rs b/crates/bevy_forge_runtime/src/gizmo.rs index 96c25aa..3af71db 100644 --- a/crates/bevy_forge_runtime/src/gizmo.rs +++ b/crates/bevy_forge_runtime/src/gizmo.rs @@ -20,7 +20,8 @@ use bevy::{ }; use crate::{ - plugin::{editor_active, Selection}, + history::{History, TransformEdit}, + plugin::{editor_active, EditorState, Selection}, ui::EditorLayout, }; @@ -104,6 +105,9 @@ pub struct GizmoDrag { pub axis: Option, /// `Transform.translation` of the entity when the drag began (parent-local). pub start_translation: Vec3, + /// Full `Transform` of the entity when the drag began, used to record one + /// undo entry on release. `None` outside a drag. + pub start_transform: Option, /// First ray/plane hit in world space. pub start_world_hit: Vec3, /// World-space gizmo origin when the drag began. @@ -395,6 +399,7 @@ fn begin_gizmo_drag( entity: Some(entity), axis: Some(axis), start_translation: target_transform.translation, + start_transform: Some(*target_transform), start_world_hit, origin, axis_world, @@ -417,7 +422,12 @@ fn update_gizmo_drag( return; } if !mouse.pressed(MouseButton::Left) { - drag.clear(); + // The release itself is handled by `end_gizmo_drag`, which records the + // single edit for the whole drag. A press state lost without a release + // event (for example a lost window focus) just cancels the stale drag. + if !mouse.just_released(MouseButton::Left) { + drag.clear(); + } return; } let (Some(entity), Some(_axis)) = (drag.entity, drag.axis) else { @@ -461,11 +471,47 @@ fn update_gizmo_drag( transform.translation = translation; } -/// Ends the drag on left release. Runs even when the editor is disabled. -fn end_gizmo_drag(mouse: Res>, mut drag: ResMut) { - if drag.active && mouse.just_released(MouseButton::Left) { - drag.clear(); +/// Builds the single history edit for a finished gizmo drag. +/// +/// The translate gizmo only edits `translation`, so a press/release without +/// movement records nothing; a changed drag records one `before`/`after` edit. +fn finished_drag_edit( + entity: Entity, + before: Transform, + after: Transform, +) -> Option { + (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>, + state: Res, + mut history: ResMut, + transforms: Query<&Transform>, + mut drag: ResMut, +) { + 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. @@ -724,4 +770,30 @@ mod tests { assert_vec3_approx(rebuilt.translation, transform.translation); assert_vec3_approx(*rebuilt.forward(), *transform.forward()); } + + #[test] + fn drag_state_starts_without_a_start_transform() { + let drag = GizmoDrag::default(); + assert!(!drag.active); + assert!(drag.start_transform.is_none()); + } + + #[test] + fn finished_drag_edit_only_records_translation_changes() { + let entity = Entity::PLACEHOLDER; + let before = Transform::from_xyz(1.0, 2.0, 3.0); + + assert!( + finished_drag_edit(entity, before, before).is_none(), + "a press without movement must not record an edit" + ); + + let mut moved = before; + moved.translation.x += 0.5; + let recorded = finished_drag_edit(entity, before, moved) + .expect("a drag that changed translation records one edit"); + assert_eq!(recorded.entity, entity); + assert_eq!(recorded.before, before); + assert_eq!(recorded.after, moved); + } } diff --git a/crates/bevy_forge_runtime/src/history.rs b/crates/bevy_forge_runtime/src/history.rs new file mode 100644 index 0000000..7607e17 --- /dev/null +++ b/crates/bevy_forge_runtime/src/history.rs @@ -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, + redo: Vec, +} + +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 { + 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 { + 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::() + .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) -> Option { + 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>, + mut history: ResMut, + 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::(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::(); + app.init_resource::>(); + app.insert_resource(crate::plugin::EditorState::default()); + app.add_plugins(HistoryPlugin); + + let missing = Entity::PLACEHOLDER; + app.world_mut().resource_mut::().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::>(); + keys.press(KeyCode::ControlLeft); + keys.press(KeyCode::KeyZ); + } + app.update(); + assert!(app.world().resource::().undo.is_empty()); + assert_eq!(app.world().resource::().redo.len(), 1); + + // Ctrl+Shift+Z moves it back; still no entity and no panic. + { + let mut keys = app.world_mut().resource_mut::>(); + // 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::().undo.len(), 1); + assert!(app.world().resource::().redo.is_empty()); + } + + #[test] + fn hotkey_mapping_requires_ctrl_and_distinguishes_redo() { + let mut keys = ButtonInput::::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)); + } +} diff --git a/crates/bevy_forge_runtime/src/inspector.rs b/crates/bevy_forge_runtime/src/inspector.rs index 28461a2..56c3170 100644 --- a/crates/bevy_forge_runtime/src/inspector.rs +++ b/crates/bevy_forge_runtime/src/inspector.rs @@ -11,11 +11,13 @@ use bevy::{ reflect::{enums::VariantType, PartialReflect, ReflectMut, ReflectRef}, text::FontSize, ui_widgets::{ - Slider, SliderRange, SliderStep, SliderThumb, SliderValue, TrackClick, ValueChange, + Slider, SliderDragState, SliderRange, SliderStep, SliderThumb, SliderValue, TrackClick, + ValueChange, }, }; use crate::{ + history::{History, TransformEdit}, plugin::{editor_active, Selection}, 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, +} + +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 { + 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 { + 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( change: On>, - sliders: Query<&InspectorSlider>, + mut sliders: Query<(&InspectorSlider, &mut InspectorPendingEdit)>, mut transforms: Query<&mut Transform>, + mut history: ResMut, mut commands: Commands, ) { - let Ok(slider) = sliders.get(change.source) else { + let Ok((slider, mut pending)) = sliders.get_mut(change.source) else { return; }; let Ok(mut transform) = transforms.get_mut(slider.entity) else { 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()]; 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 @@ -250,6 +316,43 @@ fn on_slider_value_change( .entity(change.source) .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, + mut sliders: Query<( + &InspectorSlider, + &mut InspectorPendingEdit, + Option<&SliderDragState>, + )>, + transforms: Query<&Transform>, + mut history: ResMut, +) { + 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. @@ -394,8 +497,10 @@ fn spawn_slider_row( SliderRange::new(SLIDER_MIN, SLIDER_MAX), SliderStep(SLIDER_STEP), InspectorSlider { entity, axis }, + InspectorPendingEdit::default(), )) .observe(on_slider_value_change) + .observe(on_slider_pointer_release) .with_children(|slider| { // Background rail. slider.spawn(( @@ -781,4 +886,120 @@ mod tests { assert!(!apply_f32_path(&mut transform, &["nonexistent"], 1.0)); 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()); + } } diff --git a/crates/bevy_forge_runtime/src/lib.rs b/crates/bevy_forge_runtime/src/lib.rs index 8ccc0bd..71a1d68 100644 --- a/crates/bevy_forge_runtime/src/lib.rs +++ b/crates/bevy_forge_runtime/src/lib.rs @@ -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 -//! (toolbar, hierarchy panel, inspector panel, status bar) and the module plugins -//! that provide hierarchy, inspector, gizmo and screenshot behavior. +//! (toolbar, hierarchy panel, inspector panel, status bar), the scene bridge that +//! loads a `.forge.ron` scene into the live ECS and the module plugins that +//! provide hierarchy, inspector, gizmo and screenshot behavior. //! //! The plugin is designed to be added after `DefaultPlugins`: //! @@ -15,16 +16,22 @@ pub mod gizmo; pub mod hierarchy; +pub mod history; pub mod inspector; pub mod plugin; +pub mod scene; pub mod screenshot; +pub mod shortcuts; pub mod ui; pub use gizmo::GizmoPlugin; pub use hierarchy::HierarchyPlugin; +pub use history::HistoryPlugin; pub use inspector::InspectorPlugin; pub use plugin::{editor_active, EditorPlugin, EditorState, InspectorSource, Selection}; +pub use scene::{SceneConfig, SceneDocument, ScenePlugin}; pub use screenshot::ScreenshotPlugin; +pub use shortcuts::ShortcutsPlugin; pub use ui::{ EditorLayout, EditorRoot, HierarchyPanel, InspectorPanel, StatusBar, Toolbar, UiTheme, }; diff --git a/crates/bevy_forge_runtime/src/plugin.rs b/crates/bevy_forge_runtime/src/plugin.rs index 5af857b..7d40b71 100644 --- a/crates/bevy_forge_runtime/src/plugin.rs +++ b/crates/bevy_forge_runtime/src/plugin.rs @@ -3,8 +3,9 @@ use bevy::prelude::*; use crate::{ - gizmo::GizmoPlugin, hierarchy::HierarchyPlugin, inspector::InspectorPlugin, - screenshot::ScreenshotPlugin, ui::shell::EditorUiPlugin, + gizmo::GizmoPlugin, hierarchy::HierarchyPlugin, history::HistoryPlugin, + inspector::InspectorPlugin, scene::ScenePlugin, screenshot::ScreenshotPlugin, + shortcuts::ShortcutsPlugin, ui::shell::EditorUiPlugin, }; /// Installs the Bevy Forge live editor on top of `DefaultPlugins`. @@ -20,6 +21,9 @@ impl Plugin for EditorPlugin { .init_resource::() .add_plugins(( EditorUiPlugin, + ScenePlugin, + ShortcutsPlugin, + HistoryPlugin, HierarchyPlugin, InspectorPlugin, GizmoPlugin, diff --git a/crates/bevy_forge_runtime/src/scene.rs b/crates/bevy_forge_runtime/src/scene.rs new file mode 100644 index 0000000..3f58c41 --- /dev/null +++ b/crates/bevy_forge_runtime/src/scene.rs @@ -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 { + 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 { + // 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, + /// 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>, + mut materials: ResMut>, + config: Option>, + selection: Option>, +) { + 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, + first_mesh: Option, + first_entity: Option, +} + +/// Spawns every node of `doc` in depth-first order, linking children to parents. +fn spawn_document( + commands: &mut Commands, + meshes: &mut Assets, + materials: &mut Assets, + 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`; +/// - `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, + materials: &mut Assets, + node: &EntityNode, + parent: Option, +) -> 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 { + 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 { + 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, + materials: &mut Assets, +) -> Option { + 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 = 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, + first_mesh: Option, + } + + 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>, + mut materials: ResMut>, + doc: Res, + ) { + 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::>() + .init_resource::>() + .insert_resource(TestDoc(doc)) + .add_systems(Startup, spawn_test_doc); + app.update(); + app + } + + fn component_names(app: &App, entity: Entity) -> BTreeSet { + 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::(); + + 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::>().len(), 6); + assert_eq!(world.resource::>().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::(camera).unwrap().as_str(), "Camera"); + assert!(world.get::(camera).is_some()); + assert_eq!( + world.get::(camera), + Some(&Tonemapping::AcesFitted) + ); + let camera_transform = world.get::(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::(spawned.entities[&2]).unwrap(); + assert_eq!(sun.illuminance, 11_000.0); + assert!(sun.shadow_maps_enabled); + + let glow = world.get::(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::(ground).is_some()); + assert!(world + .get::>(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::(crystal).is_some()); + assert_eq!( + world.get::(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::(); + + let stem = spawned.entities[&5]; + let bloom = spawned.entities[&6]; + let post = spawned.entities[&8]; + let glow = spawned.entities[&9]; + + assert_eq!(world.get::(bloom).map(ChildOf::parent), Some(stem)); + assert_eq!(world.get::(glow).map(ChildOf::parent), Some(post)); + assert_eq!(world.get::(stem), None); + assert_eq!(world.get::(spawned.entities[&1]), None); + assert!(world.get::(stem).unwrap().contains(&bloom)); + assert!(world.get::(post).unwrap().contains(&glow)); + + let root_count = world + .query_filtered::, With, Without)>() + .iter(world) + .count(); + let entity_count = world + .query_filtered::, With)>() + .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::(); + 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::(a).is_some()); + assert_eq!(app.world().get::(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::(); + for id in &ids { + assert!( + world.get::(spawned.entities[id]).is_some(), + "primitive {id} must spawn a Mesh3d" + ); + } + assert!(world.get::(spawned.entities[&asset]).is_none()); + assert_eq!(world.resource::>().len(), 5); + assert_eq!(world.resource::>().len(), 5); + } +} diff --git a/crates/bevy_forge_runtime/src/shortcuts.rs b/crates/bevy_forge_runtime/src/shortcuts.rs new file mode 100644 index 0000000..bf164f9 --- /dev/null +++ b/crates/bevy_forge_runtime/src/shortcuts.rs @@ -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>, + document: Option>, + 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>, + selection: Res, + 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>, + document: Option>, + mut selection: ResMut, + cameras: Query<(), Or<(With, With)>>, + 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>, + document: Option>, + mut selection: ResMut, + cameras: Query<(), Or<(With, With)>>, + mut commands: Commands, + mut meshes: ResMut>, + mut materials: ResMut>, +) { + 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, With)>>, +) -> 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) -> bool { + keys.any_pressed([KeyCode::ControlLeft, KeyCode::ControlRight]) +} + +fn any_modifier_held(keys: &ButtonInput) -> 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 { + 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, + ) -> 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::>() + .init_resource::>() + .init_resource::>() + .insert_resource(EditorState::default()) + .insert_resource(Selection(None)); + app + } + + fn press(app: &mut App, keys: impl IntoIterator) { + let mut input = app.world_mut().resource_mut::>(); + input.clear(); + for key in keys { + input.press(key); + } + } + + fn spawn_named(world: &mut World, name: &'static str, parent: Option) -> 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::().0 = Some(target); + app.add_plugins(ShortcutsPlugin); + + press(&mut app, [KeyCode::KeyF]); + app.update(); + + let camera = *app.world().get::(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::(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::().0 = Some(target); + press(&mut app, [KeyCode::ControlLeft, KeyCode::KeyF]); + app.update(); + let camera = *app.world().get::(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::(); + 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::().0, None); + + let world = app.world(); + assert!(world.get::(root_ecs).is_none()); + assert!(world.get::(child_ecs).is_none()); + assert!(world.get::(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::(); + 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::().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::(new_root_ecs).unwrap().as_str(), + "Root Copy" + ); + assert_eq!( + world.get::(new_child_ecs).map(ChildOf::parent), + Some(new_root_ecs) + ); + assert!(world.get::(new_root_ecs).is_some()); + assert!(world.get::(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::().enabled = false; + app.add_plugins(ShortcutsPlugin); + + press(&mut app, [KeyCode::Delete]); + app.update(); + + assert!(app + .world() + .resource::() + .doc + .entity(root) + .is_some()); + assert_eq!(app.world().resource::().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::(); + 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::().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); + } +} diff --git a/docs/m1-core-1000x640.png b/docs/m1-core-1000x640.png new file mode 100644 index 0000000..e2e2c1b Binary files /dev/null and b/docs/m1-core-1000x640.png differ diff --git a/docs/m1-core-1440x900.png b/docs/m1-core-1440x900.png new file mode 100644 index 0000000..2b694a7 Binary files /dev/null and b/docs/m1-core-1440x900.png differ diff --git a/docs/m1-core.md b/docs/m1-core.md new file mode 100644 index 0000000..b6fb06d --- /dev/null +++ b/docs/m1-core.md @@ -0,0 +1,106 @@ +# Bevy Forge v2 — M1 Core + +Status: **M1 Core complete and independently verified (`pass_with_risks`)**. M0 is committed at +`59212c9`; M1 turns the M0 spike into a scene-editing slice that reads and writes the existing +`bevy_forge_core` `.forge.ron` format. + +![Bevy Forge M1 Core — garden scene](m1-core-1440x900.png) + +_1000×640 variant: [m1-core-1000x640.png](m1-core-1000x640.png)._ + +## What M1 adds + +- **Real scene load**: `FORGE_PROJECT` + `FORGE_SCENE` load a `.forge.ron` scene through + `bevy_forge_core` into the live ECS. The garden demo is the reference scene. +- **Save**: Ctrl+S syncs edited transforms/names/visibility back into the scene document and writes + a `.forge.ron` file (`FORGE_SAVE_PATH`, default `scenes/edited.forge.ron`). +- **Transform undo/redo**: Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y for inspector-slider and gizmo-drag + edits. +- **Hotkeys**: Ctrl+S save, F focus camera, Delete delete the selected subtree, Ctrl+D duplicate + the selected subtree. +- Focused tests: **66 runtime tests** (scene 6, shortcuts 6, history 8, inspector 13, gizmo 10, + plus the M0 suite), all green. +- Independent verification: **V11 pass_with_risks** (`.swarm/m1/reviews/V11.md`), including a + post-review delta after the camera guard. Runtime **66 tests**, workspace **412 passed, 2 ignored**. +- Interactive checks done: load 9/7, slider edit → Ctrl+Z → Ctrl+Shift+Z, Ctrl+S with a valid saved + RON (garden source untouched), Ctrl+D duplicate, Delete, F focus, camera delete/duplicate refusal, + no panic. + +## Run + +```bash +cd bevy-forge +FORGE_PROJECT=examples/demo-project \ +FORGE_SCENE=scenes/garden.forge.ron \ +cargo run -p bevy_forge_runtime --example editor_live +``` + +| Variable | Default | Meaning | +|---|---|---| +| `FORGE_PROJECT` | `examples/demo-project` | project root with a `Cargo.toml` containing bevy | +| `FORGE_SCENE` | `scenes/garden.forge.ron` | scene to load, relative to the project root | +| `FORGE_SAVE_PATH` | `scenes/edited.forge.ron` | Ctrl+S output, relative to the project root | + +The scene plugin loads when at least one of `FORGE_PROJECT`/`FORGE_SCENE` is set; the command above +sets both. The garden scene has 9 entities / 7 roots (Camera, Sun, Ground, Shrub, Flower Stem, Flower Bloom, +Orbit Crystal, Lantern Post, Lantern Glow). The custom `forge_demo::Orbit` component is skipped in +the live ECS with a warning; all 3D meshes, lights, camera, names, transforms and visibility load. + +## Controls + +| Input | Action | +|---|---| +| F1 / **Editor (F1)** | Toggle the editor UI | +| Click hierarchy row | Select entity | +| Drag inspector Translation X/Y/Z | Move selected entity | +| Drag gizmo axis | Move selected entity | +| Right/middle drag, wheel | Orbit / pan / zoom camera | +| **Ctrl+S** | Save `.forge.ron` | +| **Ctrl+Z** | Undo transform edit | +| **Ctrl+Shift+Z / Ctrl+Y** | Redo transform edit | +| **F** | Focus camera on selected entity | +| **Delete** | Delete selected subtree | +| **Ctrl+D** | Duplicate selected subtree | + +Delete/duplicate are structural edits and are **not undoable in M1**; undo/redo is Transform-only. + +## Architecture + +``` +crates/bevy_forge_runtime/ +├── src/scene.rs .forge.ron load, ECS spawn, save/sync, delete/duplicate helpers +├── src/shortcuts.rs Ctrl+S / F / Delete / Ctrl+D +├── src/history.rs transform command stack + Ctrl+Z/Y +├── src/inspector.rs M0 reflection inspector + history capture +├── src/gizmo.rs M0 camera/gizmo + history capture +├── src/hierarchy.rs M0 live tree +└── examples/editor_live.rs +``` + +The runtime now depends on `bevy_forge_core` (Bevy-free model/persistence). Core `Transform` uses +XYZ Euler degrees; the bridge converts to/from Bevy quaternions with +`bevy_forge_core::math::{quat_from_euler_deg, quat_to_euler_deg}`. + +## Limits (M1) + +- 3D only in the live bridge; Sprite/Text2d/Camera2d components are skipped (M2). +- Custom components are skipped in ECS but preserved in the document on save. +- Delete/duplicate are not undoable; multi-select/docking/terminal/play mode are out of scope. +- Deleting or duplicating a `Camera3d` subtree is refused with a warning in M1: the editor camera is + adopted from the scene, and a second active camera would break the viewport. +- V11 residual: a synthetic portal drag can be shorter than one Bevy frame, so the live gizmo drag + → Ctrl+Z path and the inspector click/snap finalizer remain unit/helper-tested rather than + scripted end-to-end. Slider-drag undo is interactively verified. +- The garden `Camera3d` is adopted by the editor camera; do not add a second active camera. +- Saving must be explicit (Ctrl+S); there is no autosave loop yet. +- Hover styling and screenshot determinism: park the pointer outside the window for byte-stable + screenshots (M0 caveat). +- A synthetic portal drag can be shorter than one Bevy frame, so the live gizmo-drag → Ctrl+Z path + is covered by transform helper/unit tests and the unchanged M0 drag code, not by a scripted + mouse hold; the slider undo path is interactively verified. + +## Verification + +M0 acceptance is in [`m0-spike.md`](m0-spike.md). M1 was independently verified by V11: +`.swarm/m1/reviews/V11.md` (initial and post-fix delta). The exact commands are in +`.swarm/m1/plan.md`; the final report with actual spend is in the local M1 coordination dir.