diff --git a/README.md b/README.md index 373dbf3..05980ec 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,19 @@ cargo run -p bevy_forge_runtime --example editor_live See [docs/m1-core.md](docs/m1-core.md) for the garden demo, hotkeys and M1 limits. +### M2 Live — play mode, assets, export, hot reload + +M2 adds Play/Pause/Stop with transform snapshot/restore, a project asset browser, BSN/Rust export +and a `.forge.ron` transform watcher. + +```bash +FORGE_PROJECT=examples/demo-project \ +FORGE_SCENE=scenes/garden.forge.ron \ +cargo run -p bevy_forge_runtime --example editor_live +``` + +See [docs/m2-live.md](docs/m2-live.md) for controls and M2 limits. + ## Two viewports | 2D scene editing | 3D wireframe preview | diff --git a/crates/bevy_forge_runtime/examples/editor_live.rs b/crates/bevy_forge_runtime/examples/editor_live.rs index 84f7eec..0c14e36 100644 --- a/crates/bevy_forge_runtime/examples/editor_live.rs +++ b/crates/bevy_forge_runtime/examples/editor_live.rs @@ -12,14 +12,25 @@ //! `FORGE_SCENE=""` disables scene loading. With no scene (or a load failure) //! the editor still starts with its placeholder camera and empty hierarchy. +use std::path::PathBuf; + use bevy::{prelude::*, window::WindowResolution}; use bevy_forge_runtime::EditorPlugin; fn main() { + // Let AssetServer resolve project-relative paths (`textures/coin.png`) from the + // demo project's assets directory, not from the runtime crate's own assets. + let project = + std::env::var("FORGE_PROJECT").unwrap_or_else(|_| "examples/demo-project".to_string()); + let project = std::fs::canonicalize(&project).unwrap_or_else(|_| PathBuf::from(&project)); + if project.join("assets").is_dir() { + std::env::set_var("BEVY_ASSET_ROOT", &project); + } + App::new() .add_plugins(DefaultPlugins.set(WindowPlugin { primary_window: Some(Window { - title: "Bevy Forge — M1 Core".into(), + title: "Bevy Forge — M2 Live".into(), resolution: WindowResolution::new(1440, 900).with_scale_factor_override(1.0), ..default() }), diff --git a/crates/bevy_forge_runtime/src/assets.rs b/crates/bevy_forge_runtime/src/assets.rs new file mode 100644 index 0000000..d05e6cd --- /dev/null +++ b/crates/bevy_forge_runtime/src/assets.rs @@ -0,0 +1,1274 @@ +//! Toggleable project asset browser (T22). +//! +//! The browser lists [`AssetIndex`] entries scanned from the loaded project's +//! `assets/` directory, filters them by [`AssetFilter`] and previews the selected +//! image. Drag-into-scene is deliberately deferred to M3: the core +//! `bevy_forge_core::MaterialData` has no texture field yet, so the panel only +//! lists, filters and previews. +//! +//! The panel is created here (not in the fixed UI shell) as a child of the +//! editor root, aligned with the viewport: below the toolbar, inside the left +//! hierarchy / right inspector panels, above the status bar. +//! +//! ## Preview loading +//! +//! Images are first requested through [`AssetServer`] whenever the file lives +//! below Bevy's configured asset root. Project assets commonly live outside that +//! root (the `editor_live` example runs with `FORGE_PROJECT=examples/demo-project` +//! while Bevy's default root is `crates/bevy_forge_runtime/assets`); such files +//! are decoded from disk into `Assets` so the preview still works without +//! changing the example or the asset server configuration. + +use std::path::{Component, Path, PathBuf}; + +use bevy::{ + asset::{LoadState, RenderAssetUsages}, + ecs::query::QueryFilter, + image::{CompressedImageFormats, ImageSampler, ImageType}, + picking::hover::Hovered, + prelude::*, + text::FontSize, + ui::{ + widget::{ImageNode, NodeImageMode}, + ScrollPosition, Selected, + }, + ui_widgets::{Activate, Button as UiButton, ListBox, ListItem, ScrollArea, ValueChange}, +}; +use bevy_forge_core::{AssetEntry, AssetIndex, AssetKind}; + +use crate::{ + plugin::editor_active, + scene::SceneDocument, + ui::{EditorLayout, EditorRoot, UiTheme}, +}; + +/// Height of the asset browser panel in logical pixels. +pub const ASSET_PANEL_HEIGHT: f32 = 190.0; +/// Maximum width of the image preview in logical pixels. +pub const MAX_PREVIEW_WIDTH: f32 = 200.0; +/// Maximum height of the image preview in logical pixels. +pub const MAX_PREVIEW_HEIGHT: f32 = 140.0; +/// Width actually used by the preview image. Kept below the maximum so the path, +/// kind and status labels fit into the ~190px panel. +const PREVIEW_IMAGE_WIDTH: f32 = 200.0; +/// Height actually used by the preview image. +const PREVIEW_IMAGE_HEIGHT: f32 = 88.0; +/// Width of the preview column. +const PREVIEW_COLUMN_WIDTH: f32 = 216.0; + +/// Toggleable asset browser state. +/// +/// `selected` is an index into [`AssetIndex::entries`], not into the filtered +/// view, so a selection keeps its meaning when the kind filter changes. A +/// selection that stops matching the active filter is cleared by +/// [`AssetBrowserState::normalize_selection`]. +#[derive(Resource, Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct AssetBrowserState { + /// Whether the bottom panel is visible. + pub open: bool, + /// Kind filter applied to the asset list. + pub filter: AssetFilter, + /// Selected asset index into the full, unfiltered index. + pub selected: Option, +} + +impl AssetBrowserState { + /// Flips the panel open state. + pub fn toggle(&mut self) { + self.open = !self.open; + } + + /// The selected entry, if the index still contains it and it passes the filter. + pub fn selected_entry<'a>(&self, index: &'a AssetIndex) -> Option<&'a AssetEntry> { + let selected = self.selected?; + let entry = index.entries.get(selected)?; + if self.filter.accepts(entry.kind) { + Some(entry) + } else { + None + } + } + + /// Clears the selection when it points outside the index or no longer + /// matches the active filter. Returns `true` when the state changed. + pub fn normalize_selection(&mut self, index: &AssetIndex) -> bool { + if self.selected_entry(index).is_some() || self.selected.is_none() { + return false; + } + self.selected = None; + true + } +} + +/// Kind filter for the asset list. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum AssetFilter { + /// Show every asset kind. + #[default] + All, + /// Show images only. + Images, + /// Show meshes only. + Meshes, + /// Show audio only. + Audio, +} + +impl AssetFilter { + /// All filters in display order. + pub const ALL: [Self; 4] = [Self::All, Self::Images, Self::Meshes, Self::Audio]; + + /// Button / header label. + pub const fn label(self) -> &'static str { + match self { + Self::All => "All", + Self::Images => "Images", + Self::Meshes => "Meshes", + Self::Audio => "Audio", + } + } + + /// Whether an asset of `kind` is shown by this filter. + pub const fn accepts(self, kind: AssetKind) -> bool { + match self { + Self::All => true, + Self::Images => matches!(kind, AssetKind::Image), + Self::Meshes => matches!(kind, AssetKind::Mesh), + Self::Audio => matches!(kind, AssetKind::Audio), + } + } +} + +/// Indices into [`AssetIndex::entries`] accepted by `filter`, in index order. +pub fn filtered_indices(index: &AssetIndex, filter: AssetFilter) -> Vec { + index + .entries + .iter() + .enumerate() + .filter_map(|(position, entry)| filter.accepts(entry.kind).then_some(position)) + .collect() +} + +/// How a selected image asset can be turned into a preview handle. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PreviewSource { + /// Path relative to Bevy's configured asset root; load through [`AssetServer`]. + AssetServer(String), + /// Absolute project file outside the Bevy asset root; decode into `Assets`. + ProjectFile(PathBuf), + /// Not an image (or no preview possible). + None, +} + +/// Pure preview plan for one asset entry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreviewPlan { + /// Asset path relative to the project assets directory. + pub path: String, + /// Detected asset kind. + pub kind: AssetKind, + /// Preview loading strategy. + pub source: PreviewSource, +} + +/// Plans how `entry` is previewed. +/// +/// Images below `asset_root` use [`AssetServer`] with a path relative to that +/// root. Everything else falls back to reading the project file directly, +/// because Bevy's asset root is independent of the Forge project root. +pub fn plan_preview(entry: &AssetEntry, asset_root: Option<&Path>) -> PreviewPlan { + let source = if entry.kind != AssetKind::Image { + PreviewSource::None + } else if let Some(relative) = asset_root + .and_then(|root| entry.abs_path.strip_prefix(root).ok()) + .and_then(relative_asset_path) + { + PreviewSource::AssetServer(relative) + } else { + PreviewSource::ProjectFile(entry.abs_path.clone()) + }; + PreviewPlan { + path: entry.path.clone(), + kind: entry.kind, + source, + } +} + +/// Converts a relative filesystem path to the `/`-separated asset path syntax. +fn relative_asset_path(path: &Path) -> Option { + let mut parts = Vec::new(); + for component in path.components() { + match component { + Component::Normal(part) => parts.push(part.to_string_lossy().into_owned()), + Component::CurDir => {} + _ => return None, + } + } + if parts.is_empty() { + None + } else { + Some(parts.join("/")) + } +} + +/// Bevy's default file asset root (`/assets`), used to decide whether an +/// asset can be requested through [`AssetServer`]. +/// +/// Mirrors Bevy's `FileAssetReader` base path resolution (which is not public in +/// 0.20): `BEVY_ASSET_ROOT`, then `CARGO_MANIFEST_DIR`, then the executable +/// directory. The default `AssetPlugin` file path is `assets`. +fn default_asset_root() -> PathBuf { + let base = std::env::var_os("BEVY_ASSET_ROOT") + .map(PathBuf::from) + .or_else(|| std::env::var_os("CARGO_MANIFEST_DIR").map(PathBuf::from)) + .or_else(|| { + std::env::current_exe() + .ok() + .and_then(|exe| exe.parent().map(Path::to_path_buf)) + }) + .unwrap_or_default(); + base.join("assets") +} + +/// Decodes an image file from disk into an [`Image`]. +/// +/// Only formats enabled in the Bevy build (here PNG) can be decoded; a failing +/// decode is reported to the caller and logged once by the preview system. +fn decode_image_file(path: &Path) -> Result { + let bytes = std::fs::read(path).map_err(|error| format!("cannot read: {error}"))?; + let extension = path + .extension() + .and_then(|extension| extension.to_str()) + .map(str::to_ascii_lowercase) + .unwrap_or_default(); + if extension.is_empty() { + return Err("missing file extension".to_string()); + } + Image::from_buffer( + &bytes, + ImageType::Extension(&extension), + CompressedImageFormats::NONE, + true, + ImageSampler::Default, + RenderAssetUsages::default(), + ) + .map_err(|error| format!("cannot decode: {error}")) +} + +/// Installs the project asset browser. +pub struct AssetBrowserPlugin; + +impl Plugin for AssetBrowserPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .init_resource::() + .init_resource::() + .add_systems(PostStartup, spawn_asset_browser) + .add_systems(Update, asset_browser_system.run_if(editor_active)) + .add_systems(Update, style_asset_rows.run_if(editor_active)); + } +} + +// --------------------------------------------------------------------------- +// Internal state +// --------------------------------------------------------------------------- + +/// Scanned asset index plus the view state needed to rebuild the panel. +#[derive(Resource, Debug, Default)] +struct AssetBrowserData { + index: AssetIndex, + /// Incremented on every scan; part of the rebuild signature. + generation: u64, + /// Directory the current index was scanned from. + scanned_dir: Option, + /// Last scan error, if the assets directory could not be read. + scan_error: Option, + /// Signature of the last rendered view. + last_signature: Option, +} + +/// Everything that requires a list/preview rebuild when it changes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct BrowserSignature { + filter: AssetFilter, + selected: Option, + generation: u64, +} + +/// Preview loading state and the handle currently shown in the panel. +#[derive(Resource, Debug, Default)] +struct AssetPreview { + key: Option, + handle: Option>, + status: PreviewStatus, +} + +/// Identity of the loaded preview. +#[derive(Debug, Clone, PartialEq, Eq)] +enum PreviewKey { + AssetServer(String), + ProjectFile(PathBuf), +} + +/// Status shown below the preview image. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +enum PreviewStatus { + #[default] + Empty, + NoPreview, + Loading, + Ready, + Failed(String), +} + +// --------------------------------------------------------------------------- +// UI markers +// --------------------------------------------------------------------------- + +/// Root node of the bottom asset browser panel. +#[derive(Component)] +pub struct AssetBrowserPanel; + +/// Scrollable asset list. +#[derive(Component)] +struct AssetList; + +/// One row in the asset list, pointing at an [`AssetIndex::entries`] index. +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)] +struct AssetListRow { + entry: usize, +} + +/// One of the All/Images/Meshes/Audio filter buttons. +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)] +struct AssetFilterButton(AssetFilter); + +/// Header text that reports the indexed / shown asset counts. +#[derive(Component)] +struct AssetCountText; + +/// Preview column root. Its children are rebuilt on selection changes. +#[derive(Component)] +struct AssetPreviewRoot; + +/// Preview status line (`preview`, `loading preview...`, ...). +#[derive(Component)] +struct AssetPreviewStatusText; + +// --------------------------------------------------------------------------- +// Startup: build the panel +// --------------------------------------------------------------------------- + +/// Builds the asset browser panel as a child of the editor root. +/// +/// Runs at `Startup` after the fixed UI shell, so [`EditorRoot`] already exists. +/// The panel starts hidden and is revealed by `B` / [`AssetBrowserState::open`]. +fn spawn_asset_browser( + mut commands: Commands, + theme: Res, + layout: Res, + roots: Query>, +) { + let Ok(root) = roots.single() else { + debug!("assets: editor root missing, asset browser not created"); + return; + }; + + let panel = commands + .spawn(( + AssetBrowserPanel, + Node { + position_type: PositionType::Absolute, + left: px(layout.left), + right: px(layout.right), + bottom: px(layout.bottom), + height: px(ASSET_PANEL_HEIGHT), + display: Display::None, + flex_direction: FlexDirection::Column, + row_gap: px(4), + padding: UiRect::all(px(6)), + border: UiRect::top(px(1)), + ..default() + }, + BackgroundColor(theme.panel_bg), + BorderColor::all(theme.border), + )) + .id(); + commands.entity(root).add_child(panel); + + commands.entity(panel).with_children(|panel| { + // Header: title, counts, deferred-drag hint. + panel + .spawn(Node { + width: percent(100), + height: px(22), + flex_shrink: 0.0, + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + column_gap: px(8), + ..default() + }) + .with_children(|header| { + header.spawn(asset_label("Assets", 14.0, theme.accent)); + header.spawn(( + AssetCountText, + asset_label("No project assets", 11.0, theme.text_muted), + )); + header.spawn(Node { + flex_grow: 1.0, + ..default() + }); + header.spawn(asset_label("drag: M3", 10.0, theme.text_muted)); + header.spawn(asset_label("B toggle", 10.0, theme.text_muted)); + }); + + // Filter buttons: All / Images / Meshes / Audio. + panel + .spawn(Node { + width: percent(100), + height: px(22), + flex_shrink: 0.0, + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + column_gap: px(4), + ..default() + }) + .with_children(|filters| { + for filter in AssetFilter::ALL { + filters + .spawn(( + UiButton, + AssetFilterButton(filter), + Node { + height: px(20), + padding: UiRect::horizontal(px(8)), + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + border: UiRect::all(px(1)), + ..default() + }, + BackgroundColor(theme.panel_bg_alt), + BorderColor::all(theme.border), + )) + .observe(filter_button_activated) + .with_children(|button| { + button.spawn(asset_label(filter.label(), 11.0, theme.text_muted)); + }); + } + }); + + // Body: scrollable list on the left, preview on the right. + panel + .spawn(Node { + width: percent(100), + flex_grow: 1.0, + min_height: px(0), + flex_direction: FlexDirection::Row, + column_gap: px(8), + ..default() + }) + .with_children(|body| { + body.spawn(( + AssetList, + ListBox, + ScrollArea, + ScrollPosition::default(), + Node { + flex_grow: 1.0, + height: percent(100), + min_height: px(0), + flex_direction: FlexDirection::Column, + overflow: Overflow::scroll_y(), + ..default() + }, + BackgroundColor(theme.panel_bg_alt), + )) + .observe(asset_row_activated); + + body.spawn(( + AssetPreviewRoot, + Node { + width: px(PREVIEW_COLUMN_WIDTH), + height: percent(100), + min_height: px(0), + flex_shrink: 0.0, + flex_direction: FlexDirection::Column, + align_items: AlignItems::FlexStart, + row_gap: px(2), + padding: UiRect::all(px(4)), + ..default() + }, + BackgroundColor(theme.panel_bg_alt), + )) + .with_children(|preview| { + preview.spawn(asset_label( + "Select an image to preview", + 11.0, + theme.text_muted, + )); + }); + }); + }); + + info!("assets: browser panel ready (B toggles)"); +} + +/// Creates a plain editor label bundle. +fn asset_label(value: impl Into, size: f32, color: Color) -> impl Bundle { + ( + Text::new(value.into()), + TextFont { + font_size: FontSize::Px(size), + ..default() + }, + TextColor(color), + ) +} + +// --------------------------------------------------------------------------- +// Input / state +// --------------------------------------------------------------------------- + +/// Observer on the list: a committed row activation selects its asset index. +fn asset_row_activated( + change: On>, + rows: Query<&AssetListRow>, + mut state: ResMut, +) { + let row_entity = change.value; + let Ok(row) = rows.get(row_entity) else { + return; + }; + state.selected = Some(row.entry); +} + +/// Observer on the filter buttons. +fn filter_button_activated( + activate: On, + buttons: Query<&AssetFilterButton>, + mut state: ResMut, +) { + let Ok(button) = buttons.get(activate.entity) else { + return; + }; + state.filter = button.0; +} + +// --------------------------------------------------------------------------- +// Update: scan, rebuild, preview +// --------------------------------------------------------------------------- + +/// Main browser system: handles `B`, scans the project assets, keeps the +/// selection/filter valid and rebuilds the list/preview when the view changes. +/// +/// Exclusive because the panel contents are despawned and rebuilt in one tick; +/// this avoids a frame where the list and the selected-row markers disagree. +fn asset_browser_system(world: &mut World) { + let toggle_requested = world + .get_resource::>() + .is_some_and(|keys| keys.just_pressed(KeyCode::KeyB)); + let mut state = *world.resource::(); + if toggle_requested { + state.toggle(); + debug!( + "assets: browser {}", + if state.open { "opened" } else { "closed" } + ); + } + + let Some(panel) = find_ui_entity::>(world) else { + write_state(world, state); + return; + }; + set_panel_display(world, panel, state.open); + + if !state.open { + world.resource_mut::().last_signature = None; + write_state(world, state); + return; + } + + let mut data = world + .remove_resource::() + .unwrap_or_default(); + let scene_assets_dir = world + .get_resource::() + .map(|document| document.project.assets_dir.clone()); + + if let Some(dir) = scene_assets_dir { + if data.scanned_dir.as_ref() != Some(&dir) { + let index = AssetIndex::scan(&dir); + match &index.error { + Some(error) => warn!("assets: {error}"), + None => info!( + "assets: indexed {} files under {} ({} images)", + index.len(), + dir.display(), + index.by_kind(AssetKind::Image).len() + ), + } + data.index = index; + data.scanned_dir = Some(dir); + data.scan_error = data.index.error.clone(); + data.generation = data.generation.wrapping_add(1); + } + } else if data.scanned_dir.is_none() { + data.scan_error = Some("no scene loaded".to_string()); + } + + state.normalize_selection(&data.index); + let filtered = filtered_indices(&data.index, state.filter); + + let theme = world.resource::().clone(); + let asset_root = default_asset_root(); + let plan = state + .selected_entry(&data.index) + .map(|entry| plan_preview(entry, Some(&asset_root))); + + let mut preview = world.remove_resource::().unwrap_or_default(); + apply_preview_plan(world, &mut preview, plan.as_ref()); + refresh_preview_load_state(world, &mut preview); + let status = preview_status_text(&preview); + + let signature = BrowserSignature { + filter: state.filter, + selected: state.selected, + generation: data.generation, + }; + if data.last_signature != Some(signature) { + update_count_text(world, &data, filtered.len()); + style_filter_buttons(world, state.filter, &theme); + rebuild_list(world, &data.index, &filtered, state.selected, &theme); + rebuild_preview( + world, + plan.as_ref(), + preview.handle.clone(), + &status, + &theme, + ); + data.last_signature = Some(signature); + } else { + update_count_text(world, &data, filtered.len()); + update_preview_status_text(world, &status); + } + + world.insert_resource(data); + world.insert_resource(preview); + write_state(world, state); +} + +/// Writes the browser state back only when it changed. +fn write_state(world: &mut World, state: AssetBrowserState) { + if world.resource::() != &state { + *world.resource_mut::() = state; + } +} + +/// Finds one UI entity matching `filter`. +fn find_ui_entity(world: &mut World) -> Option { + let mut query = world.query_filtered::(); + query.iter(world).next() +} + +/// Shows or hides the panel node. +fn set_panel_display(world: &mut World, panel: Entity, open: bool) { + let display = if open { Display::Flex } else { Display::None }; + if let Some(mut node) = world.get_mut::(panel) { + if node.display != display { + node.display = display; + } + } +} + +/// Replaces the text of the single entity marked with `F`, if it exists. +fn set_marked_text(world: &mut World, value: &str) { + let Some(entity) = find_ui_entity::(world) else { + return; + }; + if let Some(mut text) = world.get_mut::(entity) { + if text.0 != value { + text.0 = value.to_string(); + } + } +} + +/// Updates the header count / scan error line. +fn update_count_text(world: &mut World, data: &AssetBrowserData, shown: usize) { + let value = if data.scanned_dir.is_none() { + "No project assets".to_string() + } else if data.scan_error.is_some() { + "assets directory unreadable".to_string() + } else { + format!("{} assets · {} shown", data.index.len(), shown) + }; + set_marked_text::>(world, &value); +} + +/// Applies active/inactive styling to the four filter buttons. +fn style_filter_buttons(world: &mut World, filter: AssetFilter, theme: &UiTheme) { + let buttons: Vec<(Entity, AssetFilter)> = { + let mut query = world.query::<(Entity, &AssetFilterButton)>(); + query + .iter(world) + .map(|(entity, button)| (entity, button.0)) + .collect() + }; + for (entity, button_filter) in buttons { + let active = button_filter == filter; + if let Some(mut background) = world.get_mut::(entity) { + let color = if active { + theme.row_selected + } else { + theme.panel_bg_alt + }; + if background.0 != color { + background.0 = color; + } + } + let color = if active { theme.text } else { theme.text_muted }; + let children: Vec = world + .get::(entity) + .map(|children| children.to_vec()) + .unwrap_or_default(); + for child in children { + if let Some(mut text_color) = world.get_mut::(child) { + if text_color.0 != color { + text_color.0 = color; + } + } + } + } +} + +/// Rebuilds the scrollable asset rows from the filtered index. +fn rebuild_list( + world: &mut World, + index: &AssetIndex, + filtered: &[usize], + selected: Option, + theme: &UiTheme, +) { + let Some(list) = find_ui_entity::>(world) else { + return; + }; + world.entity_mut(list).despawn_children(); + + for &entry_index in filtered { + let Some(entry) = index.entries.get(entry_index) else { + continue; + }; + let is_selected = selected == Some(entry_index); + let row = world + .spawn(( + ListItem, + AssetListRow { entry: entry_index }, + Node { + width: percent(100), + height: px(20), + flex_shrink: 0.0, + padding: UiRect::left(px(6)), + align_items: AlignItems::Center, + overflow: Overflow::clip(), + ..default() + }, + BackgroundColor(if is_selected { + theme.row_selected + } else { + Color::NONE + }), + Hovered::default(), + )) + .id(); + if is_selected { + world.entity_mut(row).insert(Selected); + } + let label = world + .spawn(asset_label( + format!("{} · {}", entry.path, entry.kind.label()), + 11.0, + if is_selected { + theme.text + } else { + theme.text_muted + }, + )) + .id(); + world.entity_mut(row).add_child(label); + world.entity_mut(list).add_child(row); + } +} + +/// Rebuilds the preview column: path label, image (or `no preview`), status. +fn rebuild_preview( + world: &mut World, + plan: Option<&PreviewPlan>, + handle: Option>, + status: &str, + theme: &UiTheme, +) { + let Some(root) = find_ui_entity::>(world) else { + return; + }; + world.entity_mut(root).despawn_children(); + + let Some(plan) = plan else { + let placeholder = world + .spawn(( + AssetPreviewStatusText, + asset_label("Select an image to preview", 11.0, theme.text_muted), + )) + .id(); + world.entity_mut(root).add_child(placeholder); + return; + }; + + let path = world + .spawn(asset_label(plan.path.clone(), 9.0, theme.text_muted)) + .id(); + world.entity_mut(root).add_child(path); + + if let Some(handle) = handle { + let image = world + .spawn(( + ImageNode::new(handle).with_mode(NodeImageMode::Stretch), + Node { + width: px(PREVIEW_IMAGE_WIDTH), + height: px(PREVIEW_IMAGE_HEIGHT), + flex_shrink: 0.0, + ..default() + }, + )) + .id(); + world.entity_mut(root).add_child(image); + } else { + let placeholder = world + .spawn(asset_label("no preview", 11.0, theme.text_muted)) + .id(); + world.entity_mut(root).add_child(placeholder); + } + + let status_line = world + .spawn(( + AssetPreviewStatusText, + asset_label( + format!("{} · {}", plan.kind.label(), status), + 10.0, + theme.text_muted, + ), + )) + .id(); + world.entity_mut(root).add_child(status_line); +} + +/// Chooses / loads the preview image for `plan` when the selection changed. +fn apply_preview_plan(world: &mut World, preview: &mut AssetPreview, plan: Option<&PreviewPlan>) { + let key = plan.and_then(|plan| match &plan.source { + PreviewSource::AssetServer(path) => Some(PreviewKey::AssetServer(path.clone())), + PreviewSource::ProjectFile(path) => Some(PreviewKey::ProjectFile(path.clone())), + PreviewSource::None => None, + }); + if preview.key == key { + return; + } + preview.key = key; + preview.handle = None; + preview.status = match plan.map(|plan| &plan.source) { + None | Some(PreviewSource::None) => PreviewStatus::NoPreview, + Some(PreviewSource::AssetServer(path)) => match world.get_resource::() { + Some(asset_server) => { + preview.handle = Some(asset_server.load::(path.clone())); + PreviewStatus::Loading + } + None => PreviewStatus::Failed("asset server unavailable".to_string()), + }, + Some(PreviewSource::ProjectFile(path)) => { + match ( + decode_image_file(path), + world.get_resource_mut::>(), + ) { + (Ok(image), Some(mut images)) => { + preview.handle = Some(images.add(image)); + PreviewStatus::Ready + } + (Ok(_), None) => PreviewStatus::Failed("image assets unavailable".to_string()), + (Err(error), _) => { + warn!( + "assets: cannot preview '{}': {error}", + plan.map(|plan| plan.path.as_str()).unwrap_or_default() + ); + PreviewStatus::Failed(error) + } + } + } + }; +} + +/// Promotes a loading AssetServer preview to loaded/failed. +fn refresh_preview_load_state(world: &mut World, preview: &mut AssetPreview) { + if preview.status != PreviewStatus::Loading { + return; + } + let Some(handle) = preview.handle.as_ref().map(Handle::id) else { + return; + }; + let Some(asset_server) = world.get_resource::() else { + return; + }; + match asset_server.load_state(handle) { + LoadState::Loaded => preview.status = PreviewStatus::Ready, + LoadState::Failed(error) => { + let message = error.to_string(); + warn!("assets: preview load failed: {message}"); + preview.status = PreviewStatus::Failed(message); + } + _ => {} + } +} + +/// Human-readable status line for the preview footer. +fn preview_status_text(preview: &AssetPreview) -> String { + match &preview.status { + PreviewStatus::Empty | PreviewStatus::NoPreview => "no preview".to_string(), + PreviewStatus::Loading => "loading preview...".to_string(), + PreviewStatus::Ready => "preview".to_string(), + PreviewStatus::Failed(error) => format!("preview failed: {error}"), + } +} + +/// Updates only the preview footer when the load state changes. +fn update_preview_status_text(world: &mut World, status: &str) { + set_marked_text::>(world, status); +} + +/// Hover / selected styling for list rows. +fn style_asset_rows( + theme: Res, + state: Res, + mut rows: Query<( + &AssetListRow, + &mut BackgroundColor, + Has, + &Hovered, + &Children, + )>, + mut texts: Query<&mut TextColor>, +) { + if !state.open { + return; + } + for (row, mut background, selected, hovered, children) in &mut rows { + let is_selected = selected || state.selected == Some(row.entry); + let is_hovered = hovered.get() && !is_selected; + let color = if is_selected { + theme.row_selected + } else if is_hovered { + theme.row_hover + } else { + Color::NONE + }; + if background.0 != color { + background.0 = color; + } + let text_color = if is_selected || is_hovered { + theme.text + } else { + theme.text_muted + }; + for child in children.iter() { + if let Ok(mut text) = texts.get_mut(child) { + if text.0 != text_color { + text.0 = text_color; + } + } + } + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::sync::atomic::{AtomicU64, Ordering}; + + use bevy_forge_core::Project; + + use super::*; + + const DEMO_PROJECT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../examples/demo-project"); + + /// Creates a unique temporary project assets directory and removes it on drop. + struct TempAssets { + root: PathBuf, + } + + impl TempAssets { + fn new(tag: &str) -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let root = std::env::temp_dir().join(format!( + "bevy-forge-t22-{}-{tag}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(root.join("assets")).expect("create temp assets dir"); + let root = fs::canonicalize(root).expect("canonicalize temp dir"); + Self { root } + } + + fn assets(&self) -> PathBuf { + self.root.join("assets") + } + + fn write(&self, relative: &str, bytes: &[u8]) -> PathBuf { + let path = self.assets().join(relative); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create parent dir"); + } + fs::write(&path, bytes).expect("write temp asset"); + path + } + } + + impl Drop for TempAssets { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } + } + + fn entry(path: &str, kind: AssetKind) -> AssetEntry { + AssetEntry { + path: path.to_string(), + abs_path: PathBuf::from("/tmp/forge-assets").join(path), + kind, + size_bytes: 1, + } + } + + fn index(paths: &[(&str, AssetKind)]) -> AssetIndex { + AssetIndex { + entries: paths + .iter() + .map(|(path, kind)| entry(path, *kind)) + .collect(), + scanned_dir: PathBuf::from("/tmp/forge-assets"), + error: None, + } + } + + fn selected_paths<'a>(index: &'a AssetIndex, indices: &[usize]) -> Vec<&'a str> { + indices + .iter() + .map(|&position| index.entries[position].path.as_str()) + .collect() + } + + #[test] + fn filter_model_covers_all_images_meshes_and_audio() { + assert_eq!(AssetFilter::default(), AssetFilter::All); + assert_eq!(AssetFilter::ALL.len(), 4); + assert_eq!(AssetFilter::All.label(), "All"); + + for kind in AssetKind::all() { + assert!(AssetFilter::All.accepts(kind)); + } + assert!(AssetFilter::Images.accepts(AssetKind::Image)); + assert!(!AssetFilter::Images.accepts(AssetKind::Mesh)); + assert!(AssetFilter::Meshes.accepts(AssetKind::Mesh)); + assert!(!AssetFilter::Meshes.accepts(AssetKind::Audio)); + assert!(AssetFilter::Audio.accepts(AssetKind::Audio)); + assert!(!AssetFilter::Audio.accepts(AssetKind::Image)); + } + + #[test] + fn filtered_indices_keep_index_order_and_skip_other_kinds() { + let index = index(&[ + ("textures/coin.png", AssetKind::Image), + ("models/robot.glb", AssetKind::Mesh), + ("audio/loop.ogg", AssetKind::Audio), + ("notes.txt", AssetKind::Other), + ("textures/background.png", AssetKind::Image), + ]); + + assert_eq!( + selected_paths(&index, &filtered_indices(&index, AssetFilter::All)), + vec![ + "textures/coin.png", + "models/robot.glb", + "audio/loop.ogg", + "notes.txt", + "textures/background.png", + ] + ); + assert_eq!( + selected_paths(&index, &filtered_indices(&index, AssetFilter::Images)), + vec!["textures/coin.png", "textures/background.png",] + ); + assert_eq!( + selected_paths(&index, &filtered_indices(&index, AssetFilter::Meshes)), + vec!["models/robot.glb",] + ); + assert_eq!( + selected_paths(&index, &filtered_indices(&index, AssetFilter::Audio)), + vec!["audio/loop.ogg",] + ); + } + + #[test] + fn selection_is_normalized_against_filter_and_bounds() { + let index = index(&[ + ("textures/coin.png", AssetKind::Image), + ("models/robot.glb", AssetKind::Mesh), + ]); + + let mut state = AssetBrowserState { + filter: AssetFilter::Images, + selected: Some(1), + ..default() + }; + assert!(state.normalize_selection(&index)); + assert_eq!(state.selected, None); + assert!(state.selected_entry(&index).is_none()); + + state.selected = Some(0); + assert!(!state.normalize_selection(&index)); + assert_eq!( + state + .selected_entry(&index) + .map(|entry| entry.path.as_str()), + Some("textures/coin.png") + ); + + state.filter = AssetFilter::Meshes; + assert!(state.normalize_selection(&index)); + assert_eq!(state.selected, None); + + state.filter = AssetFilter::All; + state.selected = Some(99); + assert!(state.normalize_selection(&index)); + assert_eq!(state.selected, None); + } + + #[test] + fn temp_assets_dir_is_indexed_with_expected_kinds() { + let temp = TempAssets::new("scan"); + temp.write("textures/coin.png", b"png-bytes"); + temp.write("models/robot.glb", b"glb"); + temp.write("audio/loop.ogg", b"ogg"); + temp.write("notes.txt", b"text"); + temp.write(".hidden.png", b"hidden"); + + let index = AssetIndex::scan(&temp.assets()); + + assert_eq!(index.error, None); + assert_eq!(index.len(), 4); + assert_eq!( + index.find("textures/coin.png").map(|entry| entry.kind), + Some(AssetKind::Image) + ); + assert_eq!( + index.find("models/robot.glb").map(|entry| entry.kind), + Some(AssetKind::Mesh) + ); + assert_eq!( + index.find("audio/loop.ogg").map(|entry| entry.kind), + Some(AssetKind::Audio) + ); + assert_eq!( + selected_paths(&index, &filtered_indices(&index, AssetFilter::Images)), + vec!["textures/coin.png"] + ); + assert_eq!( + selected_paths(&index, &filtered_indices(&index, AssetFilter::Meshes)), + vec!["models/robot.glb"] + ); + assert_eq!( + selected_paths(&index, &filtered_indices(&index, AssetFilter::Audio)), + vec!["audio/loop.ogg"] + ); + } + + #[test] + fn demo_project_assets_index_lists_texture_files() { + let project = Project::open(Path::new(DEMO_PROJECT)).expect("demo project must open"); + let index = AssetIndex::scan(&project.assets_dir); + + assert_eq!(index.error, None); + for name in [ + "textures/background.png", + "textures/coin.png", + "textures/crate.png", + "textures/hat.png", + "textures/player.png", + "textures/tile.png", + ] { + assert_eq!( + index.find(name).map(|entry| entry.kind), + Some(AssetKind::Image), + "missing image asset {name}" + ); + } + assert!(index.by_kind(AssetKind::Image).len() >= 6); + } + + #[test] + fn preview_plan_uses_asset_server_for_paths_under_the_root() { + let temp = TempAssets::new("preview-server"); + let abs_path = temp.write("textures/coin.png", b"not-decoded-here"); + let image = AssetEntry { + path: "textures/coin.png".to_string(), + abs_path, + kind: AssetKind::Image, + size_bytes: 7, + }; + + let plan = plan_preview(&image, Some(&temp.assets())); + + assert_eq!(plan.path, "textures/coin.png"); + assert_eq!(plan.kind, AssetKind::Image); + assert_eq!( + plan.source, + PreviewSource::AssetServer("textures/coin.png".to_string()) + ); + } + + #[test] + fn preview_plan_falls_back_to_project_file_outside_the_root() { + let temp = TempAssets::new("preview-local"); + let abs_path = temp.write("textures/coin.png", b"not-decoded-here"); + let image = AssetEntry { + path: "textures/coin.png".to_string(), + abs_path: abs_path.clone(), + kind: AssetKind::Image, + size_bytes: 7, + }; + + let plan = plan_preview(&image, Some(Path::new("/not/the/asset/root"))); + assert_eq!(plan.source, PreviewSource::ProjectFile(abs_path.clone())); + + let no_root = plan_preview(&image, None); + assert_eq!(no_root.source, PreviewSource::ProjectFile(abs_path)); + } + + #[test] + fn preview_plan_has_no_image_for_non_images() { + let mesh = entry("models/robot.glb", AssetKind::Mesh); + let plan = plan_preview(&mesh, None); + + assert_eq!(plan.kind, AssetKind::Mesh); + assert_eq!(plan.source, PreviewSource::None); + } + + #[test] + fn demo_png_decodes_for_local_preview() { + let path = Path::new(DEMO_PROJECT).join("assets/textures/coin.png"); + let image = decode_image_file(&path).expect("demo coin.png must decode"); + + assert!(image.width() > 0); + assert!(image.height() > 0); + } + + #[test] + fn preview_dimensions_stay_within_the_contract_limits() { + assert!(PREVIEW_IMAGE_WIDTH <= MAX_PREVIEW_WIDTH); + assert!(PREVIEW_IMAGE_HEIGHT <= MAX_PREVIEW_HEIGHT); + assert_eq!(ASSET_PANEL_HEIGHT, 190.0); + } +} diff --git a/crates/bevy_forge_runtime/src/export.rs b/crates/bevy_forge_runtime/src/export.rs new file mode 100644 index 0000000..a32e9df --- /dev/null +++ b/crates/bevy_forge_runtime/src/export.rs @@ -0,0 +1,511 @@ +//! Export the live scene to BSN or imperative Rust (T23, M2 Live). +//! +//! | Shortcut | Format | File | +//! |---|---|---| +//! | `Ctrl+E` | Bevy 0.20 BSN (`bsn!` / `bsn_list!`) | `exports/-bsn.rs` | +//! | `Ctrl+Shift+E` | Imperative `Commands::spawn` Rust | `exports/-rust.rs` | +//! +//! The generated code comes from the core exporters +//! ([`bevy_forge_core::codegen::ExportFormat`]); this module only decides when to +//! run them and where to write the result. Before generating, live ECS edits are +//! synced back into the document with [`sync_doc_from_ecs`], so an export matches +//! what the viewport currently shows (the same sync `Ctrl+S` performs). +//! +//! Files are always written below `project.root/exports/`: the slug is sanitised +//! with [`bevy_forge_core::project::slugify`] and the resolved directory is +//! canonicalised and checked against the project root before writing, so a +//! crafted scene name cannot escape the project. Exporting without a loaded +//! [`SceneDocument`] is a no-op. + +use std::{ + ffi::OsString, + fmt, io, + path::{Component, Path, PathBuf}, +}; + +use bevy::prelude::*; +use bevy_forge_core::{ + codegen::{CodegenOptions, ExportFormat}, + project::slugify, + CodegenError, +}; + +use crate::{ + plugin::{editor_active, EditorState}, + scene::{sync_doc_from_ecs, SceneDocument}, +}; + +/// Project-relative directory that receives generated files. +pub const EXPORTS_DIR: &str = "exports"; + +/// Paths of the most recent successful exports. +#[derive(Resource, Debug, Default)] +pub struct ExportState { + /// Last file written by `Ctrl+E` (BSN). + pub last_bsn: Option, + /// Last file written by `Ctrl+Shift+E` (Rust). + pub last_rust: Option, +} + +/// Errors from an export attempt. +#[derive(Debug)] +pub enum ExportError { + /// The core code generator rejected the document. + Codegen(CodegenError), + /// The generated file could not be written. + Io(io::Error), + /// The computed path would leave the project root. + OutsideProject(PathBuf), +} + +impl fmt::Display for ExportError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Codegen(error) => write!(f, "code generation failed: {error}"), + Self::Io(error) => write!(f, "write failed: {error}"), + Self::OutsideProject(path) => write!( + f, + "refusing to write outside the project root: {}", + path.display() + ), + } + } +} + +impl std::error::Error for ExportError {} + +impl From for ExportError { + fn from(error: CodegenError) -> Self { + Self::Codegen(error) + } +} + +impl From for ExportError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +/// Installs the `Ctrl+E` / `Ctrl+Shift+E` export hotkeys. +pub struct ExportPlugin; + +impl Plugin for ExportPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + // `EditorPlugin` installs this too; initializing it here keeps the + // plugin self-contained for standalone apps and unit tests. + .init_resource::() + .add_systems(Update, export_shortcut.run_if(editor_active)); + } +} + +/// Resolves the export hotkey; `None` when neither chord was pressed. +fn export_request(keys: &ButtonInput) -> Option { + if !keys.any_pressed([KeyCode::ControlLeft, KeyCode::ControlRight]) + || !keys.just_pressed(KeyCode::KeyE) + { + return None; + } + let shift = keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]); + Some(if shift { + ExportFormat::Rust + } else { + ExportFormat::Bsn + }) +} + +/// `Ctrl+E` exports BSN; `Ctrl+Shift+E` exports imperative Rust. +/// +/// The live ECS is synced into the document first when a [`SceneDocument`] +/// exists; without one the shortcut does nothing. +fn export_shortcut( + keys: Res>, + document: Option>, + mut state: ResMut, + names: Query<&Name>, + transforms: Query<&Transform>, + visibilities: Query<&Visibility>, +) { + let Some(format) = export_request(&keys) else { + return; + }; + let Some(mut document) = document else { + return; + }; + + // Export what the viewport shows, not a stale document. + let synced = sync_doc_from_ecs(&mut document, &names, &transforms, &visibilities); + debug!("export: synced {synced} entities from the live ECS"); + + match write_export(&document, format) { + Ok((path, bytes)) => { + info!("export: wrote {} ({bytes} bytes)", path.display()); + match format { + ExportFormat::Bsn => state.last_bsn = Some(path), + ExportFormat::Rust => state.last_rust = Some(path), + } + } + Err(error) => warn!("export: failed: {error}"), + } +} + +/// Generates `format` from the document and writes it below `/exports/`. +/// +/// Returns the written path and the number of bytes written. +pub fn write_export( + document: &SceneDocument, + format: ExportFormat, +) -> Result<(PathBuf, usize), ExportError> { + let code = format.generate(&document.doc, &CodegenOptions::default())?; + let path = export_path(&document.project.root, &document.doc.name, format)?; + let Some(directory) = path.parent() else { + return Err(ExportError::OutsideProject(path)); + }; + std::fs::create_dir_all(directory)?; + + // Canonicalise the directory before writing so an in-project symlink cannot + // redirect the export outside the root. + let root = document.project.root.canonicalize()?; + let directory = directory.canonicalize()?; + if !directory.starts_with(&root) { + return Err(ExportError::OutsideProject(directory)); + } + let Some(file_name) = path.file_name().map(OsString::from) else { + return Err(ExportError::OutsideProject(path)); + }; + let path = directory.join(file_name); + std::fs::write(&path, code.as_bytes())?; + Ok((path, code.len())) +} + +/// Full path of the generated file for `scene_name` inside `project_root`. +/// +/// The name is slugified, so separators and `..` cannot survive into the file +/// path; the result is additionally normalized and checked against the root. +pub fn export_path( + project_root: &Path, + scene_name: &str, + format: ExportFormat, +) -> Result { + let directory = project_root.join(EXPORTS_DIR); + let path = normalize_lexical(&directory.join(export_file_name(scene_name, format))); + if !path.starts_with(project_root) { + return Err(ExportError::OutsideProject(path)); + } + Ok(path) +} + +/// File name (without the `exports/` directory) for one export format. +pub fn export_file_name(scene_name: &str, format: ExportFormat) -> String { + let tag = match format { + ExportFormat::Bsn => "bsn", + ExportFormat::Rust => "rust", + }; + format!("{}-{tag}.rs", slugify(scene_name)) +} + +/// Lexically removes `.` / `..` components without touching the filesystem. +fn normalize_lexical(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + out.pop(); + } + other => out.push(other.as_os_str()), + } + } + out +} + +#[cfg(test)] +mod tests { + use std::{ + collections::HashMap, + path::Path, + sync::atomic::{AtomicU64, Ordering}, + }; + + use super::*; + use crate::scene::{SceneDocument, DEFAULT_SCENE}; + + static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + + /// A throwaway Bevy project on disk, removed on drop. + struct TempProject { + root: PathBuf, + } + + impl TempProject { + fn new(label: &str) -> Self { + let unique = NEXT_TEMP.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir().join(format!( + "forge-t23-export-{label}-{}-{unique}", + std::process::id() + )); + std::fs::create_dir_all(&root).expect("create temp project dir"); + std::fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"forge-t23-test\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nbevy = \"0.20\"\n", + ) + .expect("write temp Cargo.toml"); + Self { root } + } + + fn open(&self) -> bevy_forge_core::Project { + bevy_forge_core::Project::open(&self.root).expect("temp project must open") + } + } + + impl Drop for TempProject { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } + } + + fn demo_project_root() -> PathBuf { + PathBuf::from(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../examples/demo-project" + )) + } + + fn garden_document(project: bevy_forge_core::Project) -> SceneDocument { + let demo = bevy_forge_core::Project::open(demo_project_root()).expect("demo project"); + let doc = demo + .load_scene(demo.root.join(DEFAULT_SCENE)) + .expect("garden scene must load"); + SceneDocument { + doc, + project, + scene_path: PathBuf::from(DEFAULT_SCENE), + save_path: PathBuf::from("scenes/out.forge.ron"), + entities: HashMap::new(), + loaded: true, + } + } + + /// A one-root document named like the garden demo, so the generated BSN + /// uses `bsn!` and the output path is `garden-demo-{bsn,rust}.rs`. + fn single_root_document(project: bevy_forge_core::Project) -> SceneDocument { + use bevy_forge_core::{ComponentData, Mesh3dData, SceneDoc}; + + let mut doc = SceneDoc::new("Garden Demo"); + let cube = doc.add_entity("Export Cube", None); + doc.add_component(cube, ComponentData::Mesh3d(Mesh3dData::default())); + SceneDocument { + doc, + project, + scene_path: PathBuf::from(DEFAULT_SCENE), + save_path: PathBuf::from("scenes/out.forge.ron"), + entities: HashMap::new(), + loaded: true, + } + } + + fn export_app(document: Option) -> App { + let mut app = App::new(); + app.init_resource::>() + .init_resource::(); + if let Some(document) = document { + app.insert_resource(document); + } + app.add_plugins(ExportPlugin); + app + } + + /// Runs one frame and clears `just_pressed`, matching `InputPlugin`. + fn tick(app: &mut App) { + app.update(); + app.world_mut() + .resource_mut::>() + .clear(); + } + + fn press(app: &mut App, keys: impl IntoIterator) { + let mut input = app.world_mut().resource_mut::>(); + input.reset_all(); + for key in keys { + input.press(key); + } + } + + #[test] + fn generated_bsn_contains_bsn_macro_and_rust_contains_commands() { + // A single-root scene is the case where the core exporter emits `bsn!`; + // multi-root scenes use `bsn_list!` (covered by the garden test below). + let temp = TempProject::new("markers"); + let document = single_root_document(temp.open()); + + let bsn = ExportFormat::Bsn + .generate(&document.doc, &CodegenOptions::default()) + .expect("BSN generation must succeed"); + assert!(bsn.contains("bsn!"), "BSN export must use the bsn! macro"); + assert!(bsn.contains("Export Cube")); + + let rust = ExportFormat::Rust + .generate(&document.doc, &CodegenOptions::default()) + .expect("Rust generation must succeed"); + assert!(rust.contains("Commands"), "Rust export must take Commands"); + assert!(rust.contains(".spawn("), "Rust export must spawn entities"); + assert!(rust.contains("Export Cube")); + } + + #[test] + fn garden_bsn_uses_list_macro_for_multiple_roots() { + let demo = bevy_forge_core::Project::open(demo_project_root()).expect("demo project"); + let doc = demo + .load_scene(demo.root.join(DEFAULT_SCENE)) + .expect("garden scene must load"); + assert!(doc.roots.len() > 1, "the garden scene has several roots"); + + let bsn = ExportFormat::Bsn + .generate(&doc, &CodegenOptions::default()) + .expect("BSN generation must succeed"); + assert!( + bsn.contains("bsn_list!"), + "multi-root scenes must use bsn_list!" + ); + assert!(bsn.contains("Name(\"Camera\")")); + assert!(bsn.contains("Name(\"Ground\")")); + } + + #[test] + fn slug_and_path_safety_keep_exports_inside_the_project() { + let root = Path::new("/tmp/forge-t23/project"); + + let bsn = export_path(root, "Garden Demo", ExportFormat::Bsn).expect("safe path"); + assert_eq!(bsn, root.join("exports/garden-demo-bsn.rs")); + + let rust = export_path(root, "Garden Demo", ExportFormat::Rust).expect("safe path"); + assert_eq!(rust, root.join("exports/garden-demo-rust.rs")); + + // Separators, dots and unicode never reach the file name. + for name in [ + "../../etc/passwd", + "..\\..\\windows\\system32", + "My Scene!!", + "***", + " ", + "héllo/wörld", + ] { + let path = export_path(root, name, ExportFormat::Bsn).expect("slug must be safe"); + assert!( + path.starts_with(root), + "{name:?} must stay below the project root: {path:?}" + ); + assert_eq!( + path.parent(), + Some(root.join("exports").as_path()), + "{name:?} must stay in exports/" + ); + let file_name = path.file_name().unwrap().to_string_lossy(); + assert!( + file_name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '.'), + "unsafe characters in {file_name:?}" + ); + assert!( + !file_name.contains(".."), + "dot segments leaked into {path:?}" + ); + } + + assert_eq!(export_file_name("***", ExportFormat::Bsn), "scene-bsn.rs"); + assert_eq!( + export_file_name("../../evil Scene", ExportFormat::Rust), + "evil-scene-rust.rs" + ); + } + + #[test] + fn ctrl_e_writes_bsn_and_ctrl_shift_e_writes_rust() { + let temp = TempProject::new("write"); + let project = temp.open(); + let mut app = export_app(Some(single_root_document(project))); + + press(&mut app, [KeyCode::ControlLeft, KeyCode::KeyE]); + tick(&mut app); + + let bsn_path = { + let state = app.world().resource::(); + let path = state + .last_bsn + .clone() + .expect("Ctrl+E must record the BSN path"); + assert!(state.last_rust.is_none()); + path + }; + assert!(bsn_path.starts_with(&temp.root)); + assert!(bsn_path.ends_with("exports/garden-demo-bsn.rs")); + let bsn = std::fs::read_to_string(&bsn_path).expect("BSN export file must exist"); + assert!(bsn.contains("bsn!")); + assert_eq!( + bsn.len(), + std::fs::metadata(&bsn_path).unwrap().len() as usize + ); + + press(&mut app, [KeyCode::ControlLeft, KeyCode::KeyE]); + tick(&mut app); + // Without Shift the second press overwrites the BSN file. + let state = app.world().resource::(); + assert_eq!(state.last_bsn.as_deref(), Some(bsn_path.as_path())); + + press( + &mut app, + [KeyCode::ControlLeft, KeyCode::ShiftLeft, KeyCode::KeyE], + ); + tick(&mut app); + + let state = app.world().resource::(); + let rust_path = state + .last_rust + .clone() + .expect("Ctrl+Shift+E must record the Rust path"); + assert!(rust_path.starts_with(&temp.root)); + assert!(rust_path.ends_with("exports/garden-demo-rust.rs")); + let rust = std::fs::read_to_string(&rust_path).expect("Rust export file must exist"); + assert!(rust.contains("Commands")); + assert!(rust.contains(".spawn(")); + + // The garden source scene itself is untouched. + let demo = bevy_forge_core::Project::open(demo_project_root()).expect("demo project"); + assert!(demo.root.join(DEFAULT_SCENE).is_file()); + } + + #[test] + fn export_without_a_document_is_a_no_op() { + let mut app = export_app(None); + + press(&mut app, [KeyCode::ControlLeft, KeyCode::KeyE]); + tick(&mut app); + press( + &mut app, + [KeyCode::ControlLeft, KeyCode::ShiftLeft, KeyCode::KeyE], + ); + tick(&mut app); + + let state = app.world().resource::(); + assert!(state.last_bsn.is_none()); + assert!(state.last_rust.is_none()); + } + + #[test] + fn export_is_ignored_while_the_editor_is_disabled() { + let temp = TempProject::new("disabled"); + let project = temp.open(); + let mut app = export_app(Some(garden_document(project))); + app.world_mut().resource_mut::().enabled = false; + + press(&mut app, [KeyCode::ControlLeft, KeyCode::KeyE]); + tick(&mut app); + + assert!(app.world().resource::().last_bsn.is_none()); + assert!( + !temp.root.join("exports/garden-demo-bsn.rs").exists(), + "disabled editor must not write files" + ); + } +} diff --git a/crates/bevy_forge_runtime/src/lib.rs b/crates/bevy_forge_runtime/src/lib.rs index 71a1d68..c543f52 100644 --- a/crates/bevy_forge_runtime/src/lib.rs +++ b/crates/bevy_forge_runtime/src/lib.rs @@ -14,20 +14,27 @@ //! .run(); //! ``` +pub mod assets; +pub mod export; pub mod gizmo; pub mod hierarchy; pub mod history; pub mod inspector; +pub mod play; pub mod plugin; pub mod scene; pub mod screenshot; pub mod shortcuts; pub mod ui; +pub mod watcher; +pub use assets::AssetBrowserPlugin; +pub use export::ExportPlugin; pub use gizmo::GizmoPlugin; pub use hierarchy::HierarchyPlugin; pub use history::HistoryPlugin; pub use inspector::InspectorPlugin; +pub use play::PlayPlugin; pub use plugin::{editor_active, EditorPlugin, EditorState, InspectorSource, Selection}; pub use scene::{SceneConfig, SceneDocument, ScenePlugin}; pub use screenshot::ScreenshotPlugin; @@ -35,3 +42,4 @@ pub use shortcuts::ShortcutsPlugin; pub use ui::{ EditorLayout, EditorRoot, HierarchyPanel, InspectorPanel, StatusBar, Toolbar, UiTheme, }; +pub use watcher::WatcherPlugin; diff --git a/crates/bevy_forge_runtime/src/play.rs b/crates/bevy_forge_runtime/src/play.rs new file mode 100644 index 0000000..bb7f9a2 --- /dev/null +++ b/crates/bevy_forge_runtime/src/play.rs @@ -0,0 +1,968 @@ +//! Play/pause/stop and the scene validation summary (T21, M2 Live). +//! +//! Play mode is a snapshot/restore demo, not a full game play mode: +//! +//! - entering play or pause from editing snapshots every tracked +//! [`Transform`] once; +//! - while playing, tracked mesh entities rotate around their local Y axis; +//! - pause freezes the scene; +//! - stop restores every snapshotted transform exactly and clears the snapshot, +//! so no live edit is lost. +//! +//! The overlay lives in the top-right corner of the 3D viewport, clear of the +//! inspector. Offsets come from [`EditorLayout`], so it also stays inside the +//! viewport when the window is resized. +//! +//! Shortcuts: `P` toggles play/pause, `Shift+P` or `Escape` stops and restores. + +use bevy::{ + prelude::*, + text::FontSize, + ui_widgets::{Activate, Button as UiButton}, +}; +use bevy_forge_core::{validate_scene, Severity}; + +use crate::{ + gizmo::EditorCamera, + plugin::EditorState, + scene::SceneDocument, + ui::{EditorLayout, UiTheme}, +}; + +/// Radians per second applied to tracked meshes while playing. +const PLAY_SPIN_SPEED: f32 = 1.0; + +/// Margin between the overlay and the viewport edges, in logical pixels. +const OVERLAY_MARGIN: f32 = 10.0; + +/// Width of the overlay panel, in logical pixels. +const OVERLAY_WIDTH: f32 = 230.0; + +/// The current play mode. +#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PlayMode { + /// Normal editing: nothing animates. + #[default] + Editing, + /// The scene animates. + Playing, + /// The scene is frozen at its current pose. + Paused, +} + +impl PlayMode { + /// Short label used by the overlay. + pub const fn label(self) -> &'static str { + match self { + Self::Editing => "Editing", + Self::Playing => "Playing", + Self::Paused => "Paused", + } + } +} + +/// Play mode plus the pre-play transform snapshot. +/// +/// The snapshot is taken once, when entering play or pause from +/// [`PlayMode::Editing`], and is kept across play/pause switches. It is only +/// cleared by stop (after restoring it). +#[derive(Resource, Debug, Default)] +pub struct PlayState { + /// Current mode. + pub mode: PlayMode, + /// Pre-play transform snapshot, entity to transform. + pub snapshot: Vec<(Entity, Transform)>, +} + +/// Counts from the last [`validate_scene`] run for the loaded document. +#[derive(Resource, Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct ValidationSummary { + /// Number of error-severity issues. + pub errors: usize, + /// Number of warning-severity issues. + pub warnings: usize, + /// Number of info-severity issues. + pub infos: usize, +} + +/// Root of the play overlay panel. +#[derive(Component)] +pub struct PlayOverlay; + +/// Text node displaying the current [`PlayMode`]. +#[derive(Component)] +pub struct PlayModeText; + +/// Text node displaying the [`ValidationSummary`] counts. +#[derive(Component)] +pub struct ValidationText; + +/// Overlay button that starts or resumes play. +#[derive(Component)] +pub struct PlayButton; + +/// Overlay button that pauses the scene. +#[derive(Component)] +pub struct PauseButton; + +/// Overlay button that stops and restores the scene. +#[derive(Component)] +pub struct StopButton; + +/// Installs play/pause/stop controls, the scene animation and validation. +pub struct PlayPlugin; + +impl Plugin for PlayPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .init_resource::() + // `EditorUiPlugin` installs these too. Initializing them here keeps + // the plugin self-contained for standalone apps and unit tests. + .init_resource::() + .init_resource::() + .add_systems(Startup, build_play_overlay) + .add_systems( + Update, + ( + play_shortcuts, + animate_playing_scene, + validate_scene_document, + update_play_overlay, + sync_play_overlay_visibility, + ) + .chain(), + ); + } +} + +// --------------------------------------------------------------------------- +// Transitions +// --------------------------------------------------------------------------- + +/// Handles `P` (toggle play/pause) and `Shift+P` / `Escape` (stop). +fn play_shortcuts( + keys: Res>, + document: Option>, + mut play: ResMut, + mut transforms: Query<&mut Transform>, +) { + let shift = keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight]); + + if keys.just_pressed(KeyCode::Escape) || (shift && keys.just_pressed(KeyCode::KeyP)) { + stop_play(&mut play, &mut transforms); + return; + } + + if !keys.just_pressed(KeyCode::KeyP) { + return; + } + + match play.mode { + PlayMode::Playing => pause_play(&mut play, document.as_deref(), &mut transforms), + PlayMode::Editing | PlayMode::Paused => { + start_or_resume_play(&mut play, document.as_deref(), &mut transforms); + } + } +} + +/// Play button: enters play from editing (snapshotting) or resumes from pause. +fn on_play_activate( + _activate: On, + document: Option>, + mut play: ResMut, + mut transforms: Query<&mut Transform>, +) { + start_or_resume_play(&mut play, document.as_deref(), &mut transforms); +} + +/// Pause button: freezes the scene, snapshotting first when editing. +fn on_pause_activate( + _activate: On, + document: Option>, + mut play: ResMut, + mut transforms: Query<&mut Transform>, +) { + pause_play(&mut play, document.as_deref(), &mut transforms); +} + +/// Stop button: restores the snapshot and returns to editing. +fn on_stop_activate( + _activate: On, + mut play: ResMut, + mut transforms: Query<&mut Transform>, +) { + stop_play(&mut play, &mut transforms); +} + +/// Enters [`PlayMode::Playing`] from editing or paused. +/// +/// Entering from editing snapshots every alive tracked transform. Switching +/// from paused to playing keeps the existing snapshot; pressing play while +/// already playing is a no-op. +fn start_or_resume_play( + play: &mut PlayState, + document: Option<&SceneDocument>, + transforms: &mut Query<&mut Transform>, +) { + let Some(document) = document else { + return; + }; + match play.mode { + PlayMode::Playing => {} + PlayMode::Paused => { + play.mode = PlayMode::Playing; + info!("play: resumed"); + } + PlayMode::Editing => { + play.snapshot = snapshot_transforms(document, transforms); + play.mode = PlayMode::Playing; + info!( + "play: playing ({} transforms snapshotted)", + play.snapshot.len() + ); + } + } +} + +/// Enters [`PlayMode::Paused`], snapshotting first when coming from editing. +/// +/// Switching from playing to paused keeps the existing snapshot. +fn pause_play( + play: &mut PlayState, + document: Option<&SceneDocument>, + transforms: &mut Query<&mut Transform>, +) { + let Some(document) = document else { + return; + }; + match play.mode { + PlayMode::Paused => {} + PlayMode::Editing => { + play.snapshot = snapshot_transforms(document, transforms); + play.mode = PlayMode::Paused; + info!( + "play: paused ({} transforms snapshotted)", + play.snapshot.len() + ); + } + PlayMode::Playing => { + play.mode = PlayMode::Paused; + info!("play: paused"); + } + } +} + +/// Restores every snapshotted transform, clears the snapshot and goes back to +/// editing. +/// +/// Entities that were despawned while playing are skipped. When the scene is +/// already in editing mode with no snapshot, this is a no-op. +fn stop_play(play: &mut PlayState, transforms: &mut Query<&mut Transform>) { + if play.mode == PlayMode::Editing && play.snapshot.is_empty() { + return; + } + + let mut restored = 0; + for (entity, transform) in std::mem::take(&mut play.snapshot) { + if let Ok(mut current) = transforms.get_mut(entity) { + *current = transform; + restored += 1; + } + } + play.mode = PlayMode::Editing; + info!("play: stopped (restored {restored} transforms)"); +} + +/// Copies every alive tracked transform, sorted by entity for determinism. +fn snapshot_transforms( + document: &SceneDocument, + transforms: &Query<&mut Transform>, +) -> Vec<(Entity, Transform)> { + let mut snapshot: Vec<(Entity, Transform)> = document + .entities + .values() + .filter_map(|entity| { + transforms + .get(*entity) + .ok() + .map(|transform| (*entity, *transform)) + }) + .collect(); + snapshot.sort_by_key(|(entity, _)| *entity); + snapshot +} + +// --------------------------------------------------------------------------- +// Animation +// --------------------------------------------------------------------------- + +/// Rotates tracked mesh entities around their local Y axis while playing. +/// +/// The ground plane, cameras (scene or editor) and lights are skipped. Entities +/// that are not mapped from [`SceneDocument::entities`] are skipped too, so +/// editor-owned meshes can never be animated. Editing and pause animate +/// nothing. +fn animate_playing_scene( + time: Res