diff --git a/rmf_site_editor/Cargo.toml b/rmf_site_editor/Cargo.toml index 2a840a2a..c8ce8bc2 100644 --- a/rmf_site_editor/Cargo.toml +++ b/rmf_site_editor/Cargo.toml @@ -47,7 +47,8 @@ tracing = "0.1.37" tracing-subscriber = "0.3.1" rfd = "0.11" urdf-rs = "0.7" -sdformat_rs = { git = "https://github.com/open-rmf/sdf_rust_experimental", rev = "a5daef0"} +sdformat_rs = { git = "https://github.com/open-rmf/sdf_rust_experimental", rev = "f86344f"} +gz-fuel = { git = "https://github.com/open-rmf/gz-fuel-rs", branch = "first_implementation" } pathdiff = "*" # only enable the 'dynamic' feature if we're not building for web or windows diff --git a/rmf_site_editor/src/interaction/camera_controls.rs b/rmf_site_editor/src/interaction/camera_controls.rs index bf13cfc8..0ed89d2f 100644 --- a/rmf_site_editor/src/interaction/camera_controls.rs +++ b/rmf_site_editor/src/interaction/camera_controls.rs @@ -49,6 +49,9 @@ pub const HOVERED_OUTLINE_LAYER: u8 = 4; /// The X-Ray layer is used to show visual cues that need to be rendered /// above anything that would be obstructing them. pub const XRAY_RENDER_LAYER: u8 = 5; +/// The Model Preview layer is used by model previews to spawn and render +/// models in the engine without having them being visible to general cameras +pub const MODEL_PREVIEW_LAYER: u8 = 6; #[derive(Resource)] struct MouseLocation { diff --git a/rmf_site_editor/src/interaction/mod.rs b/rmf_site_editor/src/interaction/mod.rs index 5b064534..153dc4b8 100644 --- a/rmf_site_editor/src/interaction/mod.rs +++ b/rmf_site_editor/src/interaction/mod.rs @@ -57,6 +57,9 @@ pub use light::*; pub mod mode; pub use mode::*; +pub mod model_preview; +pub use model_preview::*; + pub mod outline; pub use outline::*; @@ -163,6 +166,7 @@ impl Plugin for InteractionPlugin { .add_plugin(CategoryVisibilityPlugin::::default()) .add_plugin(CategoryVisibilityPlugin::::default()) .add_plugin(CameraControlsPlugin) + .add_plugin(ModelPreviewPlugin) .add_system_set( SystemSet::on_update(InteractionState::Enable) .with_system(make_lift_doormat_gizmo) diff --git a/rmf_site_editor/src/interaction/model_preview.rs b/rmf_site_editor/src/interaction/model_preview.rs new file mode 100644 index 00000000..9b9d6eb7 --- /dev/null +++ b/rmf_site_editor/src/interaction/model_preview.rs @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2023 Open Source Robotics Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * +*/ + +use crate::interaction::MODEL_PREVIEW_LAYER; +use bevy::prelude::*; +use bevy::render::render_resource::{ + Extent3d, TextureDescriptor, TextureDimension, TextureFormat, TextureUsages, +}; +use bevy::render::{camera::RenderTarget, primitives::Aabb, view::RenderLayers}; +use bevy_egui::{egui::TextureId, EguiContext}; + +#[derive(Resource)] +pub struct ModelPreviewCamera { + pub camera_entity: Entity, + pub egui_handle: TextureId, + pub model_entity: Entity, +} + +pub struct ModelPreviewPlugin; + +impl FromWorld for ModelPreviewCamera { + fn from_world(world: &mut World) -> Self { + // camera + let image_size = Extent3d { + width: 320, + height: 240, + depth_or_array_layers: 1, + }; + let mut preview_image = Image { + texture_descriptor: TextureDescriptor { + label: None, + size: image_size, + dimension: TextureDimension::D2, + format: TextureFormat::Bgra8UnormSrgb, + mip_level_count: 1, + sample_count: 1, + usage: TextureUsages::TEXTURE_BINDING + | TextureUsages::COPY_DST + | TextureUsages::RENDER_ATTACHMENT, + }, + ..default() + }; + preview_image.resize(image_size); + let mut images = world.get_resource_mut::>().unwrap(); + let preview_image = images.add(preview_image); + let mut egui_context = world.get_resource_mut::().unwrap(); + // Attach the bevy image to the egui image + let egui_handle = egui_context.add_image(preview_image.clone()); + let camera_entity = world + .spawn(Camera3dBundle { + transform: Transform::from_xyz(-2.0, 2.5, 5.0).looking_at(Vec3::ZERO, Vec3::Z), + camera: Camera { + target: RenderTarget::Image(preview_image), + ..default() + }, + ..default() + }) + .insert(RenderLayers::from_layers(&[MODEL_PREVIEW_LAYER])) + .id(); + let model_entity = world + .spawn(RenderLayers::from_layers(&[MODEL_PREVIEW_LAYER])) + .id(); + + Self { + camera_entity, + egui_handle, + model_entity, + } + } +} + +impl Plugin for ModelPreviewPlugin { + fn build(&self, app: &mut App) { + app.init_resource::(); + } +} diff --git a/rmf_site_editor/src/site/fuel_cache.rs b/rmf_site_editor/src/site/fuel_cache.rs new file mode 100644 index 00000000..aa6eae6f --- /dev/null +++ b/rmf_site_editor/src/site/fuel_cache.rs @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2023 Open Source Robotics Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * +*/ + +use crate::site::{AssetSource, ModelMarker, ModelSceneRoot, TentativeModelFormat}; +use crate::site_asset_io::FUEL_API_KEY; +use crate::widgets::AssetGalleryStatus; +use bevy::prelude::*; +use bevy::tasks::IoTaskPool; +use crossbeam_channel::{Receiver, Sender}; +use gz_fuel::{FuelClient as GzFuelClient, FuelModel}; + +#[derive(Resource, Clone, Default, Deref, DerefMut)] +pub struct FuelClient(GzFuelClient); + +/// Event used to request an update to the fuel cache +pub struct UpdateFuelCache; + +#[derive(Deref, DerefMut)] +pub struct FuelCacheUpdated(Option>); + +/// Event used to set the fuel API key from the UI. Will also trigger a reload for failed assets +#[derive(Deref, DerefMut)] +pub struct SetFuelApiKey(pub String); + +/// 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 UpdateFuelCacheChannels { + pub sender: Sender, + pub receiver: Receiver, +} + +impl Default for UpdateFuelCacheChannels { + fn default() -> Self { + let (sender, receiver) = crossbeam_channel::unbounded(); + Self { sender, receiver } + } +} + +pub fn handle_update_fuel_cache_requests( + mut events: EventReader, + mut gallery_status: ResMut, + fuel_client: Res, + channels: Res, +) { + if events.iter().last().is_some() { + info!("Updating fuel cache, this might take a few minutes"); + gallery_status.fetching_cache = true; + let mut fuel_client = fuel_client.clone(); + let sender = channels.sender.clone(); + IoTaskPool::get() + .spawn(async move { + // Only write to cache in non wasm, no file system in web + #[cfg(target_arch = "wasm32")] + let write_to_disk = false; + #[cfg(not(target_arch = "wasm32"))] + let write_to_disk = true; + // Send client if update was successful + let res = fuel_client.update_cache(write_to_disk).await; + sender + .send(FuelCacheUpdated(res)) + .expect("Failed sending fuel cache update event"); + }) + .detach(); + } +} + +pub fn read_update_fuel_cache_results( + channels: Res, + mut fuel_client: ResMut, + mut gallery_status: ResMut, +) { + if let Ok(result) = channels.receiver.try_recv() { + match result.0 { + Some(models) => fuel_client.models = Some(models), + None => error!("Failed updating fuel cache"), + } + gallery_status.fetching_cache = false; + } +} + +pub fn reload_failed_models_with_new_api_key( + mut commands: Commands, + mut api_key_events: EventReader, + failed_models: Query, Without)>, +) { + if let Some(key) = api_key_events.iter().last() { + info!("New API Key set, attempting to re-download failed models"); + let mut key_guard = match FUEL_API_KEY.lock() { + Ok(key) => key, + Err(poisoned) => poisoned.into_inner(), + }; + *key_guard = Some((**key).clone()); + for e in &failed_models { + commands.entity(e).insert(TentativeModelFormat::default()); + } + } +} diff --git a/rmf_site_editor/src/site/mod.rs b/rmf_site_editor/src/site/mod.rs index fb4cf79b..f1b81e3e 100644 --- a/rmf_site_editor/src/site/mod.rs +++ b/rmf_site_editor/src/site/mod.rs @@ -48,6 +48,9 @@ pub use fiducial::*; pub mod floor; pub use floor::*; +pub mod fuel_cache; +pub use fuel_cache::*; + pub mod group; pub use group::*; @@ -154,9 +157,11 @@ impl Plugin for SitePlugin { .add_state_to_stage(SiteUpdateStage::AssignOrphans, SiteState::Off) .add_state_to_stage(CoreStage::PostUpdate, SiteState::Off) .insert_resource(ClearColor(Color::rgb(0., 0., 0.))) + .init_resource::() .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .add_event::() .add_event::() @@ -167,6 +172,8 @@ impl Plugin for SitePlugin { .add_event::() .add_event::() .add_event::() + .add_event::() + .add_event::() .add_event::() .add_plugin(ChangePlugin::>::default()) .add_plugin(RecallPlugin::>::default()) @@ -308,6 +315,7 @@ impl Plugin for SitePlugin { .with_system(add_measurement_visuals) .with_system(update_changed_measurement) .with_system(update_measurement_for_moved_anchors) + .with_system(handle_model_loaded_events) .with_system(update_constraint_for_moved_anchors) .with_system(update_constraint_for_changed_labels) .with_system(update_changed_constraint) @@ -317,12 +325,16 @@ impl Plugin for SitePlugin { .with_system(handle_new_sdf_roots) .with_system(update_model_scales) .with_system(make_models_selectable) + .with_system(propagate_model_render_layers) .with_system(handle_new_mesh_primitives) .with_system(add_drawing_visuals) .with_system(handle_loaded_drawing) .with_system(update_drawing_rank) .with_system(add_physical_camera_visuals) .with_system(add_wall_visual) + .with_system(handle_update_fuel_cache_requests) + .with_system(read_update_fuel_cache_results) + .with_system(reload_failed_models_with_new_api_key) .with_system(update_walls_for_moved_anchors) .with_system(update_walls) .with_system(update_transforms_for_changed_poses) diff --git a/rmf_site_editor/src/site/model.rs b/rmf_site_editor/src/site/model.rs index 125fab6a..ce197731 100644 --- a/rmf_site_editor/src/site/model.rs +++ b/rmf_site_editor/src/site/model.rs @@ -16,11 +16,12 @@ */ use crate::{ - interaction::{DragPlaneBundle, Selectable}, + interaction::{DragPlaneBundle, Selectable, MODEL_PREVIEW_LAYER}, site::{Category, PreventDeletion, SiteAssets}, + site_asset_io::MODEL_ENVIRONMENT_VARIABLE, SdfRoot, }; -use bevy::{asset::LoadState, gltf::Gltf, prelude::*}; +use bevy::{asset::LoadState, gltf::Gltf, prelude::*, render::view::RenderLayers}; use bevy_mod_outline::OutlineMeshExt; use rmf_site_format::{AssetSource, ModelMarker, Pending, Pose, Scale, UrdfRoot}; use smallvec::SmallVec; @@ -37,6 +38,7 @@ pub struct ModelScene { #[derive(Component, Debug, Default, Clone, PartialEq)] pub enum TentativeModelFormat { #[default] + Plain, GlbFlat, Obj, Stl, @@ -48,6 +50,7 @@ impl TentativeModelFormat { pub fn next(&self) -> Option { use TentativeModelFormat::*; match self { + Plain => Some(GlbFlat), GlbFlat => Some(Obj), Obj => Some(Stl), Stl => Some(GlbFolder), @@ -61,6 +64,7 @@ impl TentativeModelFormat { pub fn to_string(&self, model_name: &str) -> String { use TentativeModelFormat::*; match self { + Plain => "".to_owned(), Obj => ("/".to_owned() + model_name + ".obj").into(), GlbFlat => ".glb".into(), Stl => ".stl".into(), @@ -77,82 +81,25 @@ pub struct PendingSpawning(HandleUntyped); #[derive(Component, Debug, Clone, Copy)] pub struct ModelSceneRoot; -pub fn update_model_scenes( +pub fn handle_model_loaded_events( mut commands: Commands, - changed_models: Query< - (Entity, &AssetSource, &Pose, &TentativeModelFormat), - (Changed, With), - >, - asset_server: Res, - loading_models: Query<(Entity, &PendingSpawning, &Scale), With>, - spawned_models: Query< - Entity, - ( - Without, - With, - With, - ), + loading_models: Query< + (Entity, &PendingSpawning, &Scale, Option<&RenderLayers>), + With, >, mut current_scenes: Query<&mut ModelScene>, + asset_server: Res, site_assets: Res, meshes: Res>, scenes: Res>, gltfs: Res>, urdfs: Res>, sdfs: Res>, - trashcan: Res, ) { - fn spawn_model( - e: Entity, - source: &AssetSource, - pose: &Pose, - asset_server: &AssetServer, - tentative_format: &TentativeModelFormat, - commands: &mut Commands, - ) { - let mut commands = commands.entity(e); - commands - .insert(ModelScene { - source: source.clone(), - format: tentative_format.clone(), - entity: None, - }) - .insert(SpatialBundle { - transform: pose.transform(), - ..default() - }) - .insert(Category::Model); - - // For search assets, look at subfolders and iterate through file formats - // TODO(luca) This will also iterate for non search assets, fix - let asset_source = match source { - AssetSource::Search(name) => { - let model_name = name.split('/').last().unwrap(); - AssetSource::Search(name.to_owned() + &tentative_format.to_string(model_name)) - } - _ => source.clone(), - }; - let handle = asset_server.load_untyped(&String::from(&asset_source)); - commands - .insert(PreventDeletion::because( - "Waiting for model to spawn".to_string(), - )) - .insert(PendingSpawning(handle)); - } - - // There is a bug(?) in bevy scenes, which causes panic when a scene is despawned - // immediately after it is spawned. - // Work around it by checking the `spawned` container BEFORE updating it so that - // entities are only despawned at the next frame. This also ensures that entities are - // "fully spawned" before despawning. - for e in spawned_models.iter() { - commands.entity(e).remove::(); - } - // For each model that is loading, check if its scene has finished loading // yet. If the scene has finished loading, then insert it as a child of the // model entity and make it selectable. - for (e, h, scale) in loading_models.iter() { + for (e, h, scale, render_layer) in loading_models.iter() { if asset_server.get_load_state(&h.0) == LoadState::Loaded { let model_id = if let Some(gltf) = gltfs.get(&h.typed_weak::()) { Some(commands.entity(e).add_children(|parent| { @@ -213,15 +160,67 @@ pub fn update_model_scenes( }; if let Some(id) = model_id { - commands - .entity(e) - .insert(ModelSceneRoot) - .insert(Selectable::new(e)); + let mut cmd = commands.entity(e); + cmd.insert(ModelSceneRoot); + if !render_layer.is_some_and(|l| l.iter().all(|l| l == MODEL_PREVIEW_LAYER)) { + cmd.insert(Selectable::new(e)); + } current_scenes.get_mut(e).unwrap().entity = Some(id); } - commands.entity(e).remove::(); + commands + .entity(e) + .remove::<(PreventDeletion, PendingSpawning)>(); } } +} + +pub fn update_model_scenes( + mut commands: Commands, + changed_models: Query< + (Entity, &AssetSource, &Pose, &TentativeModelFormat), + (Changed, With), + >, + asset_server: Res, + mut current_scenes: Query<&mut ModelScene>, + trashcan: Res, +) { + fn spawn_model( + e: Entity, + source: &AssetSource, + pose: &Pose, + asset_server: &AssetServer, + tentative_format: &TentativeModelFormat, + commands: &mut Commands, + ) { + let mut commands = commands.entity(e); + commands + .insert(ModelScene { + source: source.clone(), + format: tentative_format.clone(), + entity: None, + }) + .insert(SpatialBundle { + transform: pose.transform(), + ..default() + }) + .insert(Category::Model); + + // For search assets, look at subfolders and iterate through file formats + // TODO(luca) This will also iterate for non search assets, fix + let asset_source = match source { + AssetSource::Search(name) => { + let model_name = name.split('/').last().unwrap(); + AssetSource::Search(name.to_owned() + &tentative_format.to_string(model_name)) + } + _ => source.clone(), + }; + let handle = asset_server.load_untyped(&String::from(&asset_source)); + commands + .insert(PreventDeletion::because( + "Waiting for model to spawn".to_string(), + )) + .insert(PendingSpawning(handle)); + } // update changed models for (e, source, pose, tentative_format) in changed_models.iter() { @@ -270,27 +269,44 @@ pub fn update_model_tentative_formats( >, asset_server: Res, ) { + static SUPPORTED_EXTENSIONS: &[&str] = &["obj", "stl", "sdf", "glb", "gltf"]; for e in changed_models.iter() { // Reset to the first format commands.entity(e).insert(TentativeModelFormat::default()); } // Check from the asset server if any format failed, if it did try the next for (e, mut tentative_format, h, source) in loading_models.iter_mut() { - match asset_server.get_load_state(&h.0) { - LoadState::Failed => { + if matches!(asset_server.get_load_state(&h.0), LoadState::Failed) { + let mut cmd = commands.entity(e); + cmd.remove::(); + // We want to iterate only for search asset types, for others just print an error + if matches!(source, AssetSource::Search(_)) { if let Some(fmt) = tentative_format.next() { *tentative_format = fmt; - commands.entity(e).remove::(); - } else { - warn!("Model with source {} not found", String::from(source)); - commands - .entity(e) - .remove::() - .remove::() - .remove::(); + cmd.remove::(); + continue; } } - _ => {} + let path = String::from(source); + let model_ext = path + .rsplit_once('.') + .map(|s| s.1.to_owned()) + .unwrap_or_else(|| tentative_format.to_string("")); + let reason = if !SUPPORTED_EXTENSIONS.iter().any(|e| model_ext.ends_with(e)) { + "Format not supported".to_owned() + } else { + match source { + AssetSource::Search(_) | AssetSource::Remote(_) => format!( + "Model not found, try using an API key if it belongs to \ + a private organization, or add its path to the {} \ + environment variable", + MODEL_ENVIRONMENT_VARIABLE + ), + _ => "Failed parsing file".to_owned(), + } + }; + warn!("Failed loading Model with source {}: {}", path, reason); + cmd.remove::(); } } } @@ -343,7 +359,7 @@ pub fn make_models_selectable( mut commands: Commands, new_scene_roots: Query, Without)>, parents: Query<&Parent>, - scene_roots: Query<&Selectable, With>, + scene_roots: Query<(&Selectable, Option<&RenderLayers>), With>, all_children: Query<&Children>, mesh_handles: Query<&Handle>, mut mesh_assets: ResMut>, @@ -358,10 +374,16 @@ pub fn make_models_selectable( // A root might be a child of another root, for example for SDF models that have multiple // submeshes. We need to traverse up to find the highest level scene to use for selecting // behavior - let selectable = AncestorIter::new(&parents, model_scene_root) + let Some((selectable, render_layers)) = AncestorIter::new(&parents, model_scene_root) .filter_map(|p| scene_roots.get(p).ok()) .last() - .unwrap_or(scene_roots.get(model_scene_root).unwrap()); + else { + continue; + }; + // If layer should not be visible, don't make it selectable + if render_layers.is_some_and(|r| r.iter().all(|l| l == MODEL_PREVIEW_LAYER)) { + continue; + } queue.push(model_scene_root); while let Some(e) = queue.pop() { @@ -389,3 +411,26 @@ pub fn make_models_selectable( } } } + +/// Assigns the render layer of the root, if present, to all the children +pub fn propagate_model_render_layers( + mut commands: Commands, + new_scene_roots: Query>, + scene_roots: Query<&RenderLayers, With>, + parents: Query<&Parent>, + mesh_entities: Query>>, + children: Query<&Children>, +) { + for e in &new_scene_roots { + let Some(render_layers) = AncestorIter::new(&parents, e) + .filter_map(|p| scene_roots.get(p).ok()) + .last() else { + continue; + }; + for c in DescendantIter::new(&children, e) { + if mesh_entities.get(c).is_ok() { + commands.entity(c).insert(render_layers.clone()); + } + } + } +} diff --git a/rmf_site_editor/src/site/sdf.rs b/rmf_site_editor/src/site/sdf.rs index 9c579b81..7293ee56 100644 --- a/rmf_site_editor/src/site/sdf.rs +++ b/rmf_site_editor/src/site/sdf.rs @@ -29,21 +29,67 @@ use rmf_site_format::{ ModelMarker, NameInSite, Pose, Rotation, Scale, WorkcellCollisionMarker, WorkcellVisualMarker, }; -// TODO(luca) reduce chances for panic and do proper error handling here +// TODO(luca) cleanup this, there are many ways models are referenced and have to be resolved in +// SDF between local, fuel and cached paths so the logic becomes quite complicated. fn compute_model_source(path: &str, uri: &str) -> AssetSource { - let binding = path.strip_prefix("search://").unwrap(); - if let Some(stripped) = uri.strip_prefix("model://") { - // Get the org name from context, model name from this and combine - let org_name = binding.split("/").next().unwrap(); - let path = org_name.to_owned() + "/" + stripped; - AssetSource::Remote(path) - } else if let Some(path_idx) = binding.rfind("/") { - // It's a path relative to this model, remove file and append uri - let (model_path, _model_name) = binding.split_at(path_idx); - AssetSource::Remote(model_path.to_owned() + "/" + uri) - } else { - AssetSource::Remote("".into()) + let mut asset_source = AssetSource::from(path); + match asset_source { + AssetSource::Remote(ref mut p) | AssetSource::Search(ref mut p) => { + let binding = p.clone(); + *p = if let Some(stripped) = uri.strip_prefix("model://") { + // Get the org name from context, model name from this and combine + if let Some(org_name) = binding.split("/").next() { + org_name.to_owned() + "/" + stripped + } else { + error!( + "Unable to extract organization name from asset source [{}]", + uri + ); + "".into() + } + } else if let Some(path_idx) = binding.rfind("/") { + // It's a path relative to this model, remove file and append uri + let (model_path, _model_name) = binding.split_at(path_idx); + model_path.to_owned() + "/" + uri + } else { + error!( + "Invalid SDF model path, Path is [{}] and model uri is [{}]", + path, uri + ); + "".into() + }; + } + AssetSource::Local(ref mut p) => { + let binding = p.clone(); + *p = if let Some(stripped) = uri.strip_prefix("model://") { + // Search for a model with the requested name in the same folder as the sdf file + // Note that this will not play well if the requested model shares files with other + // models that are placed in different folders or are in fuel, but should work for + // most local, self contained, models. + // Get the org name from context, model name from this and combine + if let Some(model_folder) = binding.rsplitn(3, "/").skip(2).next() { + model_folder.to_owned() + "/" + stripped + } else { + error!("Unable to extract model folder from asset source [{}]", uri); + "".into() + } + } else if let Some(path_idx) = binding.rfind("/") { + // It's a path relative to this model, remove file and append uri + let (model_path, _model_name) = binding.split_at(path_idx); + model_path.to_owned() + "/" + uri + } else { + error!( + "Invalid SDF model path, Path is [{}] and model uri is [{}]", + path, uri + ); + "".into() + }; + } + AssetSource::Bundled(_) | AssetSource::Package(_) => { + warn!("Requested asset source {:?} type not supported for SDFs, might behave unexpectedly", asset_source); + } } + asset_source } fn parse_scale(scale: &Option) -> Scale { diff --git a/rmf_site_editor/src/site_asset_io.rs b/rmf_site_editor/src/site_asset_io.rs index 660777ae..6a0212f6 100644 --- a/rmf_site_editor/src/site_asset_io.rs +++ b/rmf_site_editor/src/site_asset_io.rs @@ -10,6 +10,7 @@ use std::fs; use std::io; use std::io::prelude::*; use std::path::{Path, PathBuf}; +use std::sync::Mutex; use crate::urdf_loader::UrdfPlugin; use urdf_rs::utils::expand_package_path; @@ -29,7 +30,9 @@ struct SiteAssetIo { } const FUEL_BASE_URI: &str = "https://fuel.gazebosim.org/1.0"; -const MODEL_ENVIRONMENT_VARIABLE: &str = "GZ_SIM_RESOURCE_PATH"; +pub const MODEL_ENVIRONMENT_VARIABLE: &str = "GZ_SIM_RESOURCE_PATH"; + +pub static FUEL_API_KEY: Mutex> = Mutex::new(None); #[derive(Deserialize)] struct FuelErrorMsg { @@ -61,12 +64,25 @@ impl SiteAssetIo { asset_name: String, ) -> BoxedFuture<'a, Result, AssetIoError>> { Box::pin(async move { - let bytes = surf::get(remote_url.clone()) - .recv_bytes() - .await - .map_err(|e| { - AssetIoError::Io(io::Error::new(io::ErrorKind::Other, e.to_string())) - })?; + let mut req = surf::get(remote_url.clone()); + match FUEL_API_KEY.lock() { + Ok(key) => { + if let Some(key) = key.clone() { + req = req.header("Private-token", key); + } + } + Err(poisoned_key) => { + // Reset the key to None + *poisoned_key.into_inner() = None; + return Err(AssetIoError::Io(io::Error::new( + io::ErrorKind::Other, + format!("Lock poisoning detected when reading fuel API key, please set it again."), + ))); + } + } + let bytes = req.recv_bytes().await.map_err(|e| { + AssetIoError::Io(io::Error::new(io::ErrorKind::Other, e.to_string())) + })?; match serde_json::from_slice::(&bytes) { Ok(error) => { @@ -79,9 +95,10 @@ impl SiteAssetIo { ))); } Err(_) => { - // This is okay. When a GET from fuel was successful, it - // will not return a JSON that can be interpreted as a - // FuelErrorMsg + // This is actually the happy path. When a GET from fuel was + // successful, it will not return a JSON that can be + // interpreted as a FuelErrorMsg, so our attempt to parse an + // error message will fail. } } @@ -285,7 +302,6 @@ impl AssetIo for SiteAssetIo { // Order should be: // Relative to the building.yaml location, TODO, relative paths are tricky // Relative to some paths read from an environment variable (.. need to check what gz uses for models) - // For SDF Only: // Relative to a cache directory // Attempt to fetch from the server and save it to the cache directory @@ -298,14 +314,6 @@ impl AssetIo for SiteAssetIo { } } - if !asset_name.ends_with(".sdf") { - return Box::pin(async move { - Err(AssetIoError::Io(io::Error::new( - io::ErrorKind::Other, - format!("Asset {} not found", asset_name), - ))) - }); - } // Try local cache #[cfg(not(target_arch = "wasm32"))] { diff --git a/rmf_site_editor/src/widgets/create.rs b/rmf_site_editor/src/widgets/create.rs index dc4d62e6..d346fb01 100644 --- a/rmf_site_editor/src/widgets/create.rs +++ b/rmf_site_editor/src/widgets/create.rs @@ -190,6 +190,9 @@ impl<'a, 'w1, 'w2, 's1, 's2> CreateWidget<'a, 'w1, 'w2, 's1, 's2> { | AppState::SiteDrawingEditor | AppState::SiteVisualizer => {} AppState::SiteEditor => { + if ui.button("Browse fuel").clicked() { + self.events.new_model.asset_gallery_status.show = true; + } if ui.button("Spawn model").clicked() { let model = Model { source: self @@ -209,6 +212,9 @@ impl<'a, 'w1, 'w2, 's1, 's2> CreateWidget<'a, 'w1, 'w2, 's1, 's2> { } } AppState::WorkcellEditor => { + if ui.button("Browse fuel").clicked() { + self.events.new_model.asset_gallery_status.show = true; + } if ui.button("Spawn visual").clicked() { let workcell_model = WorkcellModel { geometry: Geometry::Mesh { diff --git a/rmf_site_editor/src/widgets/mod.rs b/rmf_site_editor/src/widgets/mod.rs index 89c63ed4..68a5920e 100644 --- a/rmf_site_editor/src/widgets/mod.rs +++ b/rmf_site_editor/src/widgets/mod.rs @@ -74,6 +74,9 @@ use inspector::{InspectorParams, InspectorWidget, SearchForFiducial, SearchForTe pub mod move_layer; pub use move_layer::*; +pub mod new_model; +pub use new_model::*; + #[derive(Debug, Hash, PartialEq, Eq, Clone, SystemLabel)] pub enum UiUpdateLabel { DrawUi, @@ -101,6 +104,7 @@ impl Plugin for StandardUiLayout { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .init_resource::() .init_resource::() .init_resource::() @@ -284,6 +288,7 @@ pub struct AppEvents<'w, 's> { pub request: Requests<'w, 's>, pub file_events: FileEvents<'w, 's>, pub layers: LayerEvents<'w, 's>, + pub new_model: NewModelParams<'w, 's>, pub app_state: ResMut<'w, State>, pub visibility_parameters: VisibilityParameters<'w, 's>, pub align_site: EventWriter<'w, 's, AlignSiteDrawings>, @@ -390,6 +395,15 @@ fn site_ui_layout( ConsoleWidget::new(&mut events).show(ui); }); + if events.new_model.asset_gallery_status.show { + egui::SidePanel::left("left_panel") + .resizable(true) + .exact_width(320.0) + .show(egui_context.ctx_mut(), |ui| { + NewModel::new(&mut events).show(ui); + }); + } + let egui_context = egui_context.ctx_mut(); let ui_has_focus = egui_context.wants_pointer_input() || egui_context.wants_keyboard_input() @@ -629,6 +643,15 @@ fn workcell_ui_layout( &mut menu_params, ); + if events.new_model.asset_gallery_status.show { + egui::SidePanel::left("left_panel") + .resizable(true) + .exact_width(320.0) + .show(egui_context.ctx_mut(), |ui| { + NewModel::new(&mut events).show(ui); + }); + } + let egui_context = egui_context.ctx_mut(); let ui_has_focus = egui_context.wants_pointer_input() || egui_context.wants_keyboard_input() diff --git a/rmf_site_editor/src/widgets/new_model.rs b/rmf_site_editor/src/widgets/new_model.rs new file mode 100644 index 00000000..fbb50132 --- /dev/null +++ b/rmf_site_editor/src/widgets/new_model.rs @@ -0,0 +1,286 @@ +/* + * Copyright (C) 2023 Open Source Robotics Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * +*/ + +use crate::interaction::{ChangeMode, ModelPreviewCamera, SelectAnchor3D}; +use crate::site::{AssetSource, FuelClient, Model, SetFuelApiKey, UpdateFuelCache}; +use crate::AppEvents; +use bevy::{ecs::system::SystemParam, prelude::*}; +use bevy_egui::egui::{Button, ComboBox, RichText, ScrollArea, Ui, Window}; +use gz_fuel::FuelModel; + +/// Filters applied to models in the fuel list +#[derive(Default)] +pub struct ShowAssetFilters { + pub owner: Option, + pub recall_owner: Option, + pub tag: Option, + pub recall_tag: Option, + pub private: Option, + pub recall_private: Option, +} + +/// Used to signals whether to show or hide the left side panel with the asset gallery +#[derive(Resource, Default)] +pub struct AssetGalleryStatus { + pub show: bool, + pub selected: Option, + pub cached_owners: Option>, + pub cached_tags: Option>, + pub filters: ShowAssetFilters, + pub proposed_api_key: String, + pub fetching_cache: bool, + pub show_api_window: bool, +} + +#[derive(SystemParam)] +pub struct NewModelParams<'w, 's> { + pub fuel_client: ResMut<'w, FuelClient>, + // TODO(luca) refactor to see whether we need + pub asset_gallery_status: ResMut<'w, AssetGalleryStatus>, + pub model_preview_camera: Res<'w, ModelPreviewCamera>, + pub update_cache: EventWriter<'w, 's, UpdateFuelCache>, + pub set_api_key: EventWriter<'w, 's, SetFuelApiKey>, +} + +pub struct NewModel<'a, 'w, 's> { + events: &'a mut AppEvents<'w, 's>, +} + +impl<'a, 'w, 's> NewModel<'a, 'w, 's> { + pub fn new(events: &'a mut AppEvents<'w, 's>) -> Self { + Self { events } + } + + pub fn show(self, ui: &mut Ui) { + let fuel_client = &mut self.events.new_model.fuel_client; + let gallery_status = &mut self.events.new_model.asset_gallery_status; + ui.label(RichText::new("Asset Gallery").size(18.0)); + ui.add_space(10.0); + match &fuel_client.models { + Some(models) => { + // Note, unwraps here are safe because the client will return None only if models + // are not populated which will not happen in this match branch + let owner_filter = gallery_status.filters.owner.clone(); + let mut owner_filter_enabled = owner_filter.is_some(); + ui.label(RichText::new("Filters").size(14.0)); + ui.add_space(5.0); + ui.horizontal(|ui| { + ui.checkbox(&mut owner_filter_enabled, "Owners"); + gallery_status.filters.owner = match owner_filter_enabled { + true => { + let owners = gallery_status + .cached_owners + .clone() + .or_else(|| fuel_client.get_owners()) + .unwrap(); + let mut selected = match &owner_filter { + Some(s) => s.clone(), + None => gallery_status + .filters + .recall_owner + .clone() + .unwrap_or(owners[0].clone()), + }; + ComboBox::from_id_source("Asset Owner Filter") + .selected_text(selected.clone()) + .show_ui(ui, |ui| { + for owner in owners.into_iter() { + ui.selectable_value(&mut selected, owner.clone(), owner); + } + ui.end_row(); + }); + gallery_status.filters.recall_owner = Some(selected.clone()); + Some(selected) + } + false => None, + }; + }); + + let tag_filter = gallery_status.filters.tag.clone(); + let mut tag_filter_enabled = tag_filter.is_some(); + ui.horizontal(|ui| { + ui.checkbox(&mut tag_filter_enabled, "Tags"); + gallery_status.filters.tag = match tag_filter_enabled { + true => { + let tags = gallery_status + .cached_tags + .clone() + .or_else(|| fuel_client.get_tags()) + .unwrap(); + let mut selected = match &tag_filter { + Some(s) => s.clone(), + None => gallery_status + .filters + .recall_tag + .clone() + .unwrap_or(tags[0].clone()), + }; + ComboBox::from_id_source("Asset Tag Filter") + .selected_text(selected.clone()) + .show_ui(ui, |ui| { + for tag in tags.into_iter() { + ui.selectable_value(&mut selected, tag.clone(), tag); + } + ui.end_row(); + }); + gallery_status.filters.recall_tag = Some(selected.clone()); + Some(selected) + } + false => None, + }; + }); + + let private_filter = gallery_status.filters.private.clone(); + let mut private_filter_enabled = private_filter.is_some(); + ui.horizontal(|ui| { + ui.checkbox(&mut private_filter_enabled, "Private"); + gallery_status.filters.private = match private_filter_enabled { + true => { + let mut selected = match &private_filter { + Some(s) => s.clone(), + None => gallery_status.filters.recall_private.unwrap_or(false), + }; + ComboBox::from_id_source("Asset Private Filter") + .selected_text(selected.to_string()) + .show_ui(ui, |ui| { + for private in [true, false].into_iter() { + ui.selectable_value( + &mut selected, + private, + private.to_string(), + ); + } + ui.end_row(); + }); + gallery_status.filters.recall_private = Some(selected); + Some(selected) + } + false => None, + }; + }); + + ui.add_space(10.0); + + // TODO(luca) should we cache the models by filters result to avoid calling at every + // frame? + let models = models + .iter() + .filter(|m| { + owner_filter.is_none() + | owner_filter.as_ref().is_some_and(|owner| m.owner == *owner) + }) + .filter(|m| { + private_filter.is_none() + | private_filter + .as_ref() + .is_some_and(|private| m.private == *private) + }) + .filter(|m| { + tag_filter.is_none() + | tag_filter.as_ref().is_some_and(|tag| m.tags.contains(&tag)) + }); + + ui.label(RichText::new("Models").size(14.0)); + ui.add_space(5.0); + // Show models + let mut new_selected = None; + ScrollArea::vertical() + .max_height(300.0) + .auto_shrink([false, false]) + .show(ui, |ui| { + for model in models { + let sel = gallery_status.selected.as_ref().is_some_and(|s| s == model); + if ui.selectable_label(sel, &model.name).clicked() { + new_selected = Some(model); + } + } + }); + ui.add_space(10.0); + + ui.image( + self.events.new_model.model_preview_camera.egui_handle, + bevy_egui::egui::Vec2::new(320.0, 240.0), + ); + ui.add_space(10.0); + + if gallery_status.selected.as_ref() != new_selected { + if let Some(selected) = new_selected { + // Set the model preview source to what is selected + let model_entity = self.events.new_model.model_preview_camera.model_entity; + let model = Model { + source: AssetSource::Remote( + selected.owner.clone() + "/" + &selected.name + "/model.sdf", + ), + ..default() + }; + self.events.commands.entity(model_entity).insert(model); + gallery_status.selected = Some(selected.clone()); + } + } + + if let Some(selected) = &gallery_status.selected { + if ui.button("Spawn model").clicked() { + let model = Model { + source: AssetSource::Remote( + selected.owner.clone() + "/" + &selected.name + "/model.sdf", + ), + ..default() + }; + self.events.request.change_mode.send(ChangeMode::To( + SelectAnchor3D::create_new_point().for_model(model).into(), + )); + } + } + } + None => { + ui.label("No models found"); + } + } + ui.add_space(10.0); + if gallery_status.show_api_window { + Window::new("API Key").show(ui.ctx(), |ui| { + ui.label("Key"); + ui.text_edit_singleline(&mut gallery_status.proposed_api_key); + if ui.add(Button::new("Save")).clicked() { + // Take it to avoid leaking the information in the dialog + self.events + .new_model + .set_api_key + .send(SetFuelApiKey(gallery_status.proposed_api_key.clone())); + fuel_client.token = Some(std::mem::take(&mut gallery_status.proposed_api_key)); + gallery_status.show_api_window = false; + } else if ui.add(Button::new("Close")).clicked() { + gallery_status.proposed_api_key = Default::default(); + gallery_status.show_api_window = false; + } + }); + } + if ui.add(Button::new("Set API key")).clicked() { + gallery_status.show_api_window = true; + } + if gallery_status.fetching_cache { + ui.label("Updating model cache..."); + } else { + if ui.add(Button::new("Update model cache")).clicked() { + self.events.new_model.update_cache.send(UpdateFuelCache); + } + } + if ui.add(Button::new("Close")).clicked() { + gallery_status.show = false; + } + } +} diff --git a/rmf_site_editor/src/workcell/mod.rs b/rmf_site_editor/src/workcell/mod.rs index 045ae0d9..8481b7b2 100644 --- a/rmf_site_editor/src/workcell/mod.rs +++ b/rmf_site_editor/src/workcell/mod.rs @@ -43,9 +43,11 @@ use crate::AppState; use crate::{ shapes::make_infinite_grid, site::{ - clear_model_trashcan, handle_new_mesh_primitives, handle_new_sdf_roots, - make_models_selectable, update_anchor_transforms, update_model_scales, update_model_scenes, - update_model_tentative_formats, update_transforms_for_changed_poses, + clear_model_trashcan, handle_model_loaded_events, handle_new_mesh_primitives, + handle_new_sdf_roots, handle_update_fuel_cache_requests, make_models_selectable, + propagate_model_render_layers, read_update_fuel_cache_results, + reload_failed_models_with_new_api_key, update_anchor_transforms, update_model_scales, + update_model_scenes, update_model_tentative_formats, update_transforms_for_changed_poses, }, }; @@ -100,10 +102,15 @@ impl Plugin for WorkcellEditorPlugin { SystemSet::on_update(AppState::WorkcellEditor) .with_system(add_wireframe_to_meshes) .with_system(update_constraint_dependents) + .with_system(handle_model_loaded_events) .with_system(update_model_scenes) .with_system(update_model_scales) .with_system(update_model_tentative_formats) + .with_system(propagate_model_render_layers) .with_system(make_models_selectable) + .with_system(handle_update_fuel_cache_requests) + .with_system(read_update_fuel_cache_results) + .with_system(reload_failed_models_with_new_api_key) .with_system(handle_workcell_keyboard_input) .with_system(handle_new_mesh_primitives) .with_system(change_workcell.before(load_workcell)) diff --git a/rmf_site_format/src/asset_source.rs b/rmf_site_format/src/asset_source.rs index 9e2bd14a..06f49225 100644 --- a/rmf_site_format/src/asset_source.rs +++ b/rmf_site_format/src/asset_source.rs @@ -87,8 +87,8 @@ impl Default for AssetSource { // Utility functions to add / strip prefixes for using AssetSource in AssetIo objects impl From<&Path> for AssetSource { fn from(path: &Path) -> Self { - if let Some(path) = path.to_str().and_then(|p| Some(String::from(p))) { - AssetSource::from(&path) + if let Some(path) = path.to_str() { + AssetSource::from(path) } else { AssetSource::default() } @@ -96,8 +96,8 @@ impl From<&Path> for AssetSource { } // Utility functions to add / strip prefixes for using AssetSource in AssetIo objects -impl From<&String> for AssetSource { - fn from(path: &String) -> Self { +impl From<&str> for AssetSource { + fn from(path: &str) -> Self { // TODO(luca) pattern matching here would make sure unimplemented variants are a compile error if let Some(path) = path.strip_prefix("rmf-server://").map(|p| p.to_string()) { return AssetSource::Remote(path); diff --git a/rmf_site_format/src/workcell.rs b/rmf_site_format/src/workcell.rs index 0d331acb..3506d488 100644 --- a/rmf_site_format/src/workcell.rs +++ b/rmf_site_format/src/workcell.rs @@ -250,7 +250,7 @@ impl WorkcellModel { // TODO(luca) Make a bundle for workcell models to avoid manual insertion here commands.insert(( NameInWorkcell(self.name.clone()), - AssetSource::from(filename), + AssetSource::from(filename.as_str()), self.pose.clone(), ConstraintDependents::default(), scale,