diff --git a/Cargo.lock b/Cargo.lock index f5c566d3..a365bc43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -365,9 +365,9 @@ dependencies = [ [[package]] name = "async-task" -version = "4.7.0" +version = "4.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbb36e985947064623dbd357f727af08ffd077f93d696782f3c56365fa2e2799" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "atk-sys" @@ -764,6 +764,30 @@ dependencies = [ "smallvec", ] +[[package]] +name = "bevy_impulse" +version = "0.0.1" +source = "git+https://github.com/open-rmf/bevy_impulse?branch=main#3e681a91033427c5ae24995f4e53d19f656f278f" +dependencies = [ + "anyhow", + "arrayvec", + "async-task", + "backtrace", + "bevy_app", + "bevy_core", + "bevy_derive", + "bevy_ecs", + "bevy_hierarchy", + "bevy_tasks", + "bevy_time", + "bevy_utils", + "crossbeam", + "futures", + "itertools", + "smallvec", + "thiserror", +] + [[package]] name = "bevy_infinite_grid" version = "0.10.0" @@ -1291,7 +1315,7 @@ dependencies = [ "bitflags 2.4.2", "cexpr", "clang-sys", - "itertools 0.12.1", + "itertools", "lazy_static", "lazycell", "proc-macro2", @@ -1819,6 +1843,19 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7059fff8937831a9ae6f0fe4d658ffabf58f2ca96aa9dec1c889f936f705f216" +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + [[package]] name = "crossbeam-channel" version = "0.5.12" @@ -1847,6 +1884,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df0346b5d5e76ac2fe4e327c5fd1118d6be7c51dfb18f9b7922923f287471e35" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.19" @@ -1956,7 +2002,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79127ed59a85d7687c409e9978547cffb7dc79675355ed22da6b66fd5f6ead01" dependencies = [ - "itertools 0.11.0", + "itertools", "num-traits", ] @@ -2288,12 +2334,48 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" +[[package]] +name = "futures-executor" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.30" @@ -2328,6 +2410,47 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "futures-macro" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.52", +] + +[[package]] +name = "futures-sink" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" + +[[package]] +name = "futures-task" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" + +[[package]] +name = "futures-util" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + [[package]] name = "gdk-pixbuf-sys" version = "0.18.0" @@ -2706,8 +2829,8 @@ dependencies = [ "crossbeam-channel", "dirs", "ehttp", - "futures-lite 1.13.0", - "itertools 0.11.0", + "futures-lite 2.2.0", + "itertools", "serde", "serde_json", ] @@ -2975,15 +3098,6 @@ dependencies = [ "either", ] -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.10" @@ -3897,6 +4011,12 @@ version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "piper" version = "0.2.1" @@ -4204,6 +4324,7 @@ dependencies = [ "bevy", "bevy_egui", "bevy_gltf_export", + "bevy_impulse", "bevy_infinite_grid", "bevy_mod_outline", "bevy_mod_raycast", @@ -4219,7 +4340,7 @@ dependencies = [ "futures-lite 1.13.0", "geo", "gz-fuel", - "itertools 0.12.1", + "itertools", "nalgebra", "pathdiff", "rfd", diff --git a/rmf_site_editor/Cargo.toml b/rmf_site_editor/Cargo.toml index 371dd17a..4405b978 100644 --- a/rmf_site_editor/Cargo.toml +++ b/rmf_site_editor/Cargo.toml @@ -55,6 +55,8 @@ nalgebra = "0.32.5" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] clap = { version = "4.0.10", features = ["color", "derive", "help", "usage", "suggestions"] } +bevy_impulse = { git = "https://github.com/open-rmf/bevy_impulse", branch = "main" } [target.'cfg(target_arch = "wasm32")'.dependencies] console_error_panic_hook = "0.1.7" +bevy_impulse = { git = "https://github.com/open-rmf/bevy_impulse", branch = "main", features = ["single_threaded_async"]} diff --git a/rmf_site_editor/src/keyboard.rs b/rmf_site_editor/src/keyboard.rs index 27ea2034..bf6629c3 100644 --- a/rmf_site_editor/src/keyboard.rs +++ b/rmf_site_editor/src/keyboard.rs @@ -18,7 +18,7 @@ use crate::{ interaction::{ChangeMode, ChangeProjectionMode, InteractionMode, Selection}, site::{AlignSiteDrawings, Delete}, - CreateNewWorkspace, CurrentWorkspace, LoadWorkspace, SaveWorkspace, + CreateNewWorkspace, CurrentWorkspace, SaveWorkspace, WorkspaceLoader, }; use bevy::{prelude::*, window::PrimaryWindow}; use bevy_egui::EguiContexts; @@ -50,12 +50,12 @@ fn handle_keyboard_input( mut delete: EventWriter, mut save_workspace: EventWriter, mut new_workspace: EventWriter, - mut load_workspace: EventWriter, mut change_camera_mode: EventWriter, mut debug_mode: ResMut, mut align_site: EventWriter, current_workspace: Res, primary_windows: Query>, + mut workspace_loader: WorkspaceLoader, ) { let Some(egui_context) = primary_windows .get_single() @@ -122,7 +122,7 @@ fn handle_keyboard_input( } if keyboard_input.just_pressed(KeyCode::O) { - load_workspace.send(LoadWorkspace::Dialog); + workspace_loader.load_from_dialog(); } } } diff --git a/rmf_site_editor/src/lib.rs b/rmf_site_editor/src/lib.rs index 5537b60a..d0d18cd4 100644 --- a/rmf_site_editor/src/lib.rs +++ b/rmf_site_editor/src/lib.rs @@ -230,6 +230,7 @@ impl Plugin for SiteEditor { OccupancyPlugin, WorkspacePlugin, IssuePlugin, + bevy_impulse::ImpulsePlugin::default(), )); if self.headless_export.is_none() { diff --git a/rmf_site_editor/src/main_menu.rs b/rmf_site_editor/src/main_menu.rs index d6822509..abc8502b 100644 --- a/rmf_site_editor/src/main_menu.rs +++ b/rmf_site_editor/src/main_menu.rs @@ -16,14 +16,14 @@ */ use super::demo_world::*; -use crate::{AppState, Autoload, LoadWorkspace, WorkspaceData}; +use crate::{AppState, Autoload, WorkspaceData, WorkspaceLoader}; use bevy::{app::AppExit, prelude::*, window::PrimaryWindow}; use bevy_egui::{egui, EguiContexts}; fn egui_ui( mut egui_context: EguiContexts, mut _exit: EventWriter, - mut _load_workspace: EventWriter, + mut workspace_loader: WorkspaceLoader, mut _app_state: ResMut>, autoload: Option>, primary_windows: Query>, @@ -32,7 +32,7 @@ fn egui_ui( #[cfg(not(target_arch = "wasm32"))] { if let Some(filename) = autoload.filename.take() { - _load_workspace.send(LoadWorkspace::Path(filename)); + workspace_loader.load_from_path(filename); } } return; @@ -57,23 +57,21 @@ fn egui_ui( ui.horizontal(|ui| { if ui.button("View demo map").clicked() { - _load_workspace.send(LoadWorkspace::Data(WorkspaceData::LegacyBuilding( - demo_office(), - ))); + workspace_loader.load_from_data(WorkspaceData::LegacyBuilding(demo_office())); } if ui.button("Open a file").clicked() { - _load_workspace.send(LoadWorkspace::Dialog); + workspace_loader.load_from_dialog(); } if ui.button("Create new file").clicked() { - _load_workspace.send(LoadWorkspace::BlankFromDialog); + workspace_loader.create_empty_from_dialog(); } // TODO(@mxgrey): Bring this back when we have finished developing // the key features for workcell editing. // if ui.button("Workcell Editor").clicked() { - // _load_workspace.send(LoadWorkspace::Data(WorkspaceData::Workcell( + // workspace_loader.send(LoadWorkspace::Data(WorkspaceData::Workcell( // demo_workcell(), // ))); // } diff --git a/rmf_site_editor/src/site/sdf_exporter.rs b/rmf_site_editor/src/site/sdf_exporter.rs index acf43b31..728a13ec 100644 --- a/rmf_site_editor/src/site/sdf_exporter.rs +++ b/rmf_site_editor/src/site/sdf_exporter.rs @@ -11,7 +11,7 @@ use crate::{ ChildLiftCabinGroup, CollisionMeshMarker, DoorSegments, DrawingMarker, FloorSegments, LiftDoormat, ModelSceneRoot, TentativeModelFormat, VisualMeshMarker, }, - Autoload, LoadWorkspace, + Autoload, WorkspaceLoader, }; use rmf_site_format::{ IsStatic, LevelElevation, LiftCabin, ModelMarker, NameInSite, NameOfSite, SiteID, WallMarker, @@ -51,11 +51,11 @@ pub fn headless_sdf_export( sites: Query<(Entity, &NameOfSite)>, drawings: Query>, autoload: Option>, - mut load_workspace: EventWriter, + mut workspace_loader: WorkspaceLoader, ) { if let Some(mut autoload) = autoload { if let Some(filename) = autoload.filename.take() { - load_workspace.send(LoadWorkspace::Path(filename)); + workspace_loader.load_from_path(filename); } } else { error!("Cannot perform a headless export since no site file was specified for loading"); diff --git a/rmf_site_editor/src/widgets/menu_bar.rs b/rmf_site_editor/src/widgets/menu_bar.rs index 98d2bc4f..1b077418 100644 --- a/rmf_site_editor/src/widgets/menu_bar.rs +++ b/rmf_site_editor/src/widgets/menu_bar.rs @@ -15,7 +15,7 @@ * */ -use crate::{widgets::prelude::*, AppState, CreateNewWorkspace, LoadWorkspace, SaveWorkspace}; +use crate::{widgets::prelude::*, AppState, CreateNewWorkspace, SaveWorkspace, WorkspaceLoader}; use bevy::ecs::query::Has; use bevy::prelude::*; @@ -277,7 +277,7 @@ fn top_menu_bar( In(input): In, mut new_workspace: EventWriter, mut save: EventWriter, - mut load_workspace: EventWriter, + mut workspace_loader: WorkspaceLoader, file_menu: Res, top_level_components: Query<(), Without>, children: Query<&Children>, @@ -308,7 +308,7 @@ fn top_menu_bar( .add(Button::new("Open").shortcut_text("Ctrl+O")) .clicked() { - load_workspace.send(LoadWorkspace::Dialog); + workspace_loader.load_from_dialog(); } render_sub_menu( diff --git a/rmf_site_editor/src/workspace.rs b/rmf_site_editor/src/workspace.rs index 57cae9cb..08ac7c7f 100644 --- a/rmf_site_editor/src/workspace.rs +++ b/rmf_site_editor/src/workspace.rs @@ -15,7 +15,8 @@ * */ -use bevy::{prelude::*, tasks::AsyncComputeTaskPool}; +use bevy::{ecs::system::SystemParam, prelude::*, tasks::AsyncComputeTaskPool}; +use bevy_impulse::*; use rfd::AsyncFileDialog; use std::path::PathBuf; @@ -44,20 +45,6 @@ pub struct CreateNewWorkspace; #[derive(Component)] pub struct WorkspaceMarker; -/// Used as an event to command that a workspace should be loaded. This will spawn a file open -/// dialog (in non-wasm) with allowed extensions depending on the app state -// TODO(luca) Encapsulate a list of optional filters, for example to allow users to only load -// workcells or sites -// Dialog will spawn a RFD dialog, Path will open a specific path, the others will parse embedded -// data -#[derive(Event)] -pub enum LoadWorkspace { - Dialog, - BlankFromDialog, - Path(PathBuf), - Data(WorkspaceData), -} - #[derive(Clone)] pub enum WorkspaceData { LegacyBuilding(Vec), @@ -99,21 +86,6 @@ pub struct CurrentWorkspace { pub struct LoadWorkspaceFile(pub Option, pub WorkspaceData); -/// Using channels instead of events to allow usage in wasm since, unlike event writers, they can -/// be cloned and moved into async functions therefore don't have lifetime issues -#[derive(Debug, Resource)] -pub struct LoadWorkspaceChannels { - pub sender: Sender, - pub receiver: Receiver, -} - -impl Default for LoadWorkspaceChannels { - fn default() -> Self { - let (sender, receiver) = crossbeam_channel::unbounded(); - Self { sender, receiver } - } -} - #[derive(Event)] pub struct SaveWorkspace { /// If specified workspace will be saved to requested file, otherwise the default file @@ -212,21 +184,18 @@ impl Plugin for WorkspacePlugin { fn build(&self, app: &mut App) { app.add_event::() .add_event::() - .add_event::() .add_event::() .add_event::() .add_event::() .init_resource::() .init_resource::() .init_resource::() - .init_resource::() + .init_resource::() .add_systems( Update, ( dispatch_new_workspace_events, - workspace_file_load_complete, sync_workspace_visibility, - dispatch_load_workspace_events, workspace_file_save_complete, ), ); @@ -264,211 +233,282 @@ pub fn dispatch_new_workspace_events( } } -pub fn dispatch_load_workspace_events( - load_channels: Res, - mut load_workspace: EventReader, -) { - if let Some(cmd) = load_workspace.read().last() { - match cmd { - LoadWorkspace::Dialog => { - let sender = load_channels.sender.clone(); - AsyncComputeTaskPool::get() - .spawn(async move { - if let Some(file) = AsyncFileDialog::new().pick_file().await { - let data = file.read().await; - #[cfg(not(target_arch = "wasm32"))] - let file = file.path().to_path_buf(); - #[cfg(target_arch = "wasm32")] - let file = PathBuf::from(file.file_name()); - if let Some(data) = WorkspaceData::new(&file, data) { - sender - .send(LoadWorkspaceFile(Some(file), data)) - .expect("Failed sending file event"); - } - } - }) - .detach(); - } - LoadWorkspace::BlankFromDialog => { - let sender = load_channels.sender.clone(); - #[cfg(not(target_arch = "wasm32"))] - { - AsyncComputeTaskPool::get() - .spawn(async move { - if let Some(file) = AsyncFileDialog::new().save_file().await { - let file = file.path().to_path_buf(); - let name = file - .file_stem() - .map(|s| s.to_str().map(|s| s.to_owned())) - .flatten() - .unwrap_or_else(|| "blank".to_owned()); - let data = WorkspaceData::LoadSite(LoadSite::blank_L1( - name, - Some(file.clone()), - )); - let _ = sender.send(LoadWorkspaceFile(Some(file), data)); - } - }) - .detach(); - } - #[cfg(target_arch = "wasm32")] - { - let data = - WorkspaceData::LoadSite(LoadSite::blank_L1("blank".to_owned(), None)); - sender.send(LoadWorkspaceFile(None, data)); - } - } - LoadWorkspace::Path(path) => { - if let Ok(data) = std::fs::read(&path) { - if let Some(data) = WorkspaceData::new(path, data) { - load_channels - .sender - .send(LoadWorkspaceFile(Some(path.clone()), data)) - .expect("Failed sending load event"); - } - } else { - warn!("Unable to read file [{path:?}] so it cannot be loaded"); - } - } - LoadWorkspace::Data(data) => { - load_channels - .sender - .send(LoadWorkspaceFile(None, data.clone())) - .expect("Failed sending load event"); - } - } - } -} - -/// Handles the file opening events -fn workspace_file_load_complete( +/// Service that takes workspace data and loads a site / workcell, as well as transition state. +pub fn process_load_workspace_files( + In(BlockingService { request, .. }): BlockingServiceInput, mut app_state: ResMut>, mut interaction_state: ResMut>, mut load_site: EventWriter, mut load_workcell: EventWriter, - load_channels: Res, ) { - if let Ok(result) = load_channels.receiver.try_recv() { - let LoadWorkspaceFile(default_file, data) = result; - match data { - WorkspaceData::LegacyBuilding(data) => { - info!("Opening legacy building map file"); - match BuildingMap::from_bytes(&data) { - Ok(building) => { - match building.to_site() { - Ok(site) => { - // Switch state - app_state.set(AppState::SiteEditor); - load_site.send(LoadSite { - site, - focus: true, - default_file, - }); - interaction_state.set(InteractionState::Enable); - } - Err(err) => { - error!("Failed converting to site {:?}", err); - } + let LoadWorkspaceFile(default_file, data) = request; + match data { + WorkspaceData::LegacyBuilding(data) => { + info!("Opening legacy building map file"); + match BuildingMap::from_bytes(&data) { + Ok(building) => { + match building.to_site() { + Ok(site) => { + // Switch state + app_state.set(AppState::SiteEditor); + load_site.send(LoadSite { + site, + focus: true, + default_file, + }); + interaction_state.set(InteractionState::Enable); + } + Err(err) => { + error!("Failed converting to site {:?}", err); } - } - Err(err) => { - error!("Failed loading legacy building {:?}", err); } } + Err(err) => { + error!("Failed loading legacy building {:?}", err); + } } - WorkspaceData::RonSite(data) => { - info!("Opening site file"); - match Site::from_bytes_ron(&data) { - Ok(site) => { - // Switch state - app_state.set(AppState::SiteEditor); - load_site.send(LoadSite { - site, - focus: true, - default_file, - }); - interaction_state.set(InteractionState::Enable); - } - Err(err) => { - error!("Failed loading site {:?}", err); - } + } + WorkspaceData::RonSite(data) => { + info!("Opening site file"); + match Site::from_bytes_ron(&data) { + Ok(site) => { + // Switch state + app_state.set(AppState::SiteEditor); + load_site.send(LoadSite { + site, + focus: true, + default_file, + }); + interaction_state.set(InteractionState::Enable); + } + Err(err) => { + error!("Failed loading site {:?}", err); } } - WorkspaceData::JsonSite(data) => { - info!("Opening site file"); - match Site::from_bytes_json(&data) { - Ok(site) => { - // Switch state - app_state.set(AppState::SiteEditor); - load_site.send(LoadSite { - site, - focus: true, - default_file, - }); - interaction_state.set(InteractionState::Enable); - } - Err(err) => { - error!("Failed loading site {:?}", err); - } + } + WorkspaceData::JsonSite(data) => { + info!("Opening site file"); + match Site::from_bytes_json(&data) { + Ok(site) => { + // Switch state + app_state.set(AppState::SiteEditor); + load_site.send(LoadSite { + site, + focus: true, + default_file, + }); + interaction_state.set(InteractionState::Enable); + } + Err(err) => { + error!("Failed loading site {:?}", err); } } - WorkspaceData::Workcell(data) => { - info!("Opening workcell file"); - match Workcell::from_bytes(&data) { - Ok(workcell) => { - // Switch state - app_state.set(AppState::WorkcellEditor); - load_workcell.send(LoadWorkcell { - workcell, - focus: true, - default_file, - }); - interaction_state.set(InteractionState::Enable); - } - Err(err) => { - error!("Failed loading workcell {:?}", err); - } + } + WorkspaceData::Workcell(data) => { + info!("Opening workcell file"); + match Workcell::from_bytes(&data) { + Ok(workcell) => { + // Switch state + app_state.set(AppState::WorkcellEditor); + load_workcell.send(LoadWorkcell { + workcell, + focus: true, + default_file, + }); + interaction_state.set(InteractionState::Enable); + } + Err(err) => { + error!("Failed loading workcell {:?}", err); } } - WorkspaceData::WorkcellUrdf(data) => { - info!("Importing urdf workcell"); - let Ok(utf) = std::str::from_utf8(&data) else { - error!("Failed converting urdf bytes to string"); - return; - }; - match urdf_rs::read_from_string(utf) { - Ok(urdf) => { - // TODO(luca) make this function return a result and this a match statement - match Workcell::from_urdf(&urdf) { - Ok(workcell) => { - // Switch state - app_state.set(AppState::WorkcellEditor); - load_workcell.send(LoadWorkcell { - workcell, - focus: true, - default_file, - }); - interaction_state.set(InteractionState::Enable); - } - Err(err) => { - error!("Failed converting urdf to workcell {:?}", err); - } + } + WorkspaceData::WorkcellUrdf(data) => { + info!("Importing urdf workcell"); + let Ok(utf) = std::str::from_utf8(&data) else { + error!("Failed converting urdf bytes to string"); + return; + }; + match urdf_rs::read_from_string(utf) { + Ok(urdf) => { + // TODO(luca) make this function return a result and this a match statement + match Workcell::from_urdf(&urdf) { + Ok(workcell) => { + // Switch state + app_state.set(AppState::WorkcellEditor); + load_workcell.send(LoadWorkcell { + workcell, + focus: true, + default_file, + }); + interaction_state.set(InteractionState::Enable); + } + Err(err) => { + error!("Failed converting urdf to workcell {:?}", err); } - } - Err(err) => { - error!("Failed loading urdf workcell {:?}", err); } } + Err(err) => { + error!("Failed loading urdf workcell {:?}", err); + } } - WorkspaceData::LoadSite(site) => { - app_state.set(AppState::SiteEditor); - load_site.send(site); - interaction_state.set(InteractionState::Enable); - } + } + WorkspaceData::LoadSite(site) => { + app_state.set(AppState::SiteEditor); + load_site.send(site); + interaction_state.set(InteractionState::Enable); } } } +#[derive(Resource)] +/// Services that deal with workspace loading +pub struct WorkspaceLoadingServices { + /// Service that spawns an open file dialog and loads a site accordingly. + pub load_workspace_from_dialog: Service<(), ()>, + /// Service that spawns a save file dialog then creates a site with an empty level. + pub create_empty_workspace_from_dialog: Service<(), ()>, + /// Loads the workspace at the requested path + pub load_workspace_from_path: Service, + /// Loads the workspace from the requested data + pub load_workspace_from_data: Service, +} + +impl FromWorld for WorkspaceLoadingServices { + fn from_world(world: &mut World) -> Self { + let process_load_files = world.spawn_service(process_load_workspace_files); + // Spawn all the services + let load_workspace_from_dialog = world.spawn_workflow(|scope, builder| { + scope + .input + .chain(builder) + .map_async(|_| async move { + if let Some(file) = AsyncFileDialog::new().pick_file().await { + let data = file.read().await; + #[cfg(not(target_arch = "wasm32"))] + let file = file.path().to_path_buf(); + #[cfg(target_arch = "wasm32")] + let file = PathBuf::from(file.file_name()); + let data = WorkspaceData::new(&file, data)?; + return Some(LoadWorkspaceFile(Some(file), data)); + } + None + }) + .cancel_on_none() + .then(process_load_files) + .connect(scope.terminate) + }); + + let create_empty_workspace_from_dialog = world.spawn_workflow(|scope, builder| { + scope + .input + .chain(builder) + .map_async(|_| async move { + #[cfg(not(target_arch = "wasm32"))] + { + if let Some(file) = AsyncFileDialog::new().save_file().await { + let file = file.path().to_path_buf(); + let name = file + .file_stem() + .map(|s| s.to_str().map(|s| s.to_owned())) + .flatten() + .unwrap_or_else(|| "blank".to_owned()); + let data = WorkspaceData::LoadSite(LoadSite::blank_L1( + name, + Some(file.clone()), + )); + return Some(LoadWorkspaceFile(Some(file), data)); + } + None + } + #[cfg(target_arch = "wasm32")] + { + let data = + WorkspaceData::LoadSite(LoadSite::blank_L1("blank".to_owned(), None)); + Some(LoadWorkspaceFile(None, data)) + } + }) + .cancel_on_none() + .then(process_load_files) + .connect(scope.terminate) + }); + + let load_workspace_from_path = world.spawn_workflow(|scope, builder| { + scope + .input + .chain(builder) + .map_async(|path| async move { + let Some(data) = std::fs::read(&path) + .ok() + .and_then(|data| WorkspaceData::new(&path, data)) + else { + warn!("Unable to read file [{path:?}] so it cannot be loaded"); + return None; + }; + Some(LoadWorkspaceFile(Some(path.clone()), data)) + }) + .cancel_on_none() + .then(process_load_files) + .connect(scope.terminate) + }); + + let load_workspace_from_data = world.spawn_workflow(|scope, builder| { + scope + .input + .chain(builder) + .map_block(|data| LoadWorkspaceFile(None, data)) + .then(process_load_files) + .connect(scope.terminate) + }); + + Self { + load_workspace_from_dialog, + create_empty_workspace_from_dialog, + load_workspace_from_path, + load_workspace_from_data, + } + } +} + +impl<'w, 's> WorkspaceLoader<'w, 's> { + /// Request to spawn a dialog and load a workspace + pub fn load_from_dialog(&mut self) { + self.commands + .request((), self.workspace_loading.load_workspace_from_dialog) + .detach(); + } + + /// Request to spawn a dialog to select a file and create a new site with a blank level + pub fn create_empty_from_dialog(&mut self) { + self.commands + .request( + (), + self.workspace_loading.create_empty_workspace_from_dialog, + ) + .detach(); + } + + /// Request to load a workspace from a path + pub fn load_from_path(&mut self, path: PathBuf) { + self.commands + .request(path, self.workspace_loading.load_workspace_from_path) + .detach(); + } + + /// Request to load a workspace from data + pub fn load_from_data(&mut self, data: WorkspaceData) { + self.commands + .request(data, self.workspace_loading.load_workspace_from_data) + .detach(); + } +} + +/// `SystemParam` used to request for workspace loading operations +#[derive(SystemParam)] +pub struct WorkspaceLoader<'w, 's> { + workspace_loading: Res<'w, WorkspaceLoadingServices>, + commands: Commands<'w, 's>, +} + // TODO(luca) implement this in wasm, it's supported since rfd version 0.12, however it requires // calling .write on the `FileHandle` object returned by the AsyncFileDialog. Such FileHandle is // not Send in wasm so it can't be sent to another thread through an event. We would need to