diff --git a/Cargo.lock b/Cargo.lock index dfe9bbf4..0be1d788 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -178,6 +178,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + [[package]] name = "android_log-sys" version = "0.3.2" @@ -500,7 +506,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2056,6 +2062,20 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link 0.1.3", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -3259,7 +3279,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ "rustix 1.1.2", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -3674,6 +3694,30 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.57.0", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.0.0" @@ -4082,7 +4126,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -5056,7 +5100,7 @@ dependencies = [ "libc", "redox_syscall 0.5.18", "smallvec", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -5738,6 +5782,7 @@ name = "rmf_site_format" version = "0.0.2" dependencies = [ "bevy", + "chrono", "float_eq", "glam", "once_cell", @@ -7576,7 +7621,7 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement 0.60.2", "windows-interface 0.59.3", - "windows-link", + "windows-link 0.2.1", "windows-result 0.4.1", "windows-strings 0.5.1", ] @@ -7588,7 +7633,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ "windows-core 0.62.2", - "windows-link", + "windows-link 0.2.1", "windows-threading", ] @@ -7658,6 +7703,12 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" @@ -7671,7 +7722,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ "windows-core 0.62.2", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -7698,7 +7749,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -7717,7 +7768,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -7771,7 +7822,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -7826,7 +7877,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link", + "windows-link 0.2.1", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -7843,7 +7894,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 259e4f58..856d6d09 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ serde_json = "1.0" thiserror = "1.0" glam = { version = "0.29" } # Ensure that this match's bevy_math's glam before updating. uuid = { version = "1.13"} +chrono = "*" sdformat = "0.1" urdf-rs = { version = "0.7.3"} once_cell = "1" diff --git a/assets/demo_maps/test.site.json b/assets/demo_maps/test.site.json index d97e7df9..438455d6 100644 --- a/assets/demo_maps/test.site.json +++ b/assets/demo_maps/test.site.json @@ -998,6 +998,32 @@ } }, "inclusion": "Included" + }, + "138": { + "inclusion": "Included" + }, + "139": { + "inclusion": "Included" + } + }, + "tasks": { + "138": { + "inclusion": "Included", + "params": { + "unix_millis_earliest_start_time": null, + "unix_millis_request_time": null, + "priority": null, + "labels": [] + } + }, + "139": { + "inclusion": "Included", + "params": { + "unix_millis_earliest_start_time": null, + "unix_millis_request_time": null, + "priority": null, + "labels": [] + } } }, "name": "Default Scenario", @@ -1020,6 +1046,7 @@ }, "robots": { "52": { + "fleet": "HospitalRobot", "properties": { "Collision": { "config": { @@ -1057,7 +1084,7 @@ }, "model_instances": { "53": { - "parent": 1, + "parent": 22, "name": "L1_robot", "pose": { "trans": [ @@ -1226,5 +1253,35 @@ }, "description": 129 } + }, + "tasks": { + "138": { + "Dispatch": { + "request": { + "id": "task-20250904-030419", + "category": "Go To Place", + "description": { + "location": 121 + }, + "description_display": "A", + "created_time": 1756955059673 + } + } + }, + "139": { + "Direct": { + "robot": 53, + "fleet": "HospitalRobot", + "request": { + "id": "task-20250904-030447", + "category": "Wait For", + "description": { + "duration": 30.0 + }, + "description_display": "30 seconds", + "created_time": 1756955087335 + } + } + } } } \ No newline at end of file diff --git a/crates/rmf_site_editor/src/interaction/model.rs b/crates/rmf_site_editor/src/interaction/model.rs index 13d7cbe8..665308c8 100644 --- a/crates/rmf_site_editor/src/interaction/model.rs +++ b/crates/rmf_site_editor/src/interaction/model.rs @@ -55,6 +55,5 @@ pub fn update_model_instance_visual_cues( } } } - // TODO(@xiyuoh) support task-based visual cues } } diff --git a/crates/rmf_site_editor/src/mapf_rse/config_widget.rs b/crates/rmf_site_editor/src/mapf_rse/config_widget.rs new file mode 100644 index 00000000..daac516c --- /dev/null +++ b/crates/rmf_site_editor/src/mapf_rse/config_widget.rs @@ -0,0 +1,223 @@ +/* + * Copyright (C) 2024 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 super::*; +use crate::{ + occupancy::{CalculateGrid, Grid}, + prelude::SystemState, + site::{CurrentLevel, GoToPlace, Robot, Task, TaskKind}, + widgets::view_occupancy::OccupancyDisplay, +}; +use bevy::{ + ecs::{hierarchy::ChildOf, system::SystemParam}, + prelude::*, +}; +use bevy_egui::egui::{CollapsingHeader, DragValue, Grid as EguiGrid, Ui}; +use rmf_site_egui::{Tile, WidgetSystem}; + +#[derive(SystemParam)] +pub struct MapfConfigWidget<'w, 's> { + child_of: Query<'w, 's, &'static ChildOf>, + current_level: Res<'w, CurrentLevel>, + grids: Query<'w, 's, (Entity, &'static Grid)>, + calculate_grid: EventWriter<'w, CalculateGrid>, + negotiation_request: EventWriter<'w, NegotiationRequest>, + negotiation_params: ResMut<'w, NegotiationParams>, + negotiation_debug: ResMut<'w, NegotiationDebugData>, + negotiation_task: Res<'w, NegotiationTask>, + occupancy_display: ResMut<'w, OccupancyDisplay>, + robots: Query<'w, 's, Entity, With>, + tasks: Query<'w, 's, &'static Task>, +} + +impl<'w, 's> WidgetSystem for MapfConfigWidget<'w, 's> { + fn show(_: Tile, ui: &mut Ui, state: &mut SystemState, world: &mut World) -> () { + let mut params = state.get_mut(world); + ui.separator(); + + CollapsingHeader::new("MAPF Configuration") + .default_open(true) + .show(ui, |ui| params.show_negotiation(ui)); + } +} + +impl<'w, 's> MapfConfigWidget<'w, 's> { + pub fn show_negotiation(&mut self, ui: &mut Ui) { + // Visualize + ui.horizontal(|ui| { + ui.label("Visualize"); + ui.checkbox( + &mut self.negotiation_debug.visualize_trajectories, + "Trajectories", + ); + ui.checkbox(&mut self.negotiation_debug.visualize_conflicts, "Conflicts"); + ui.checkbox(&mut self.negotiation_debug.visualize_keys, "Keys") + }); + // Toggle debug panel + ui.horizontal(|ui| { + ui.label("Debug Panel"); + ui.checkbox(&mut self.negotiation_debug.show_debug_panel, "Enabled"); + }); + + // Negotiation Request Properties + // Agent tasks + ui.separator(); + let num_tasks = self + .tasks + .iter() + .filter(|task| { + if task.request().category() == GoToPlace::::label() { + true + } else { + false + } + }) + .count(); + ui.label(format!("Tasks: {}", num_tasks)); + // Grid Info + let occupancy_grid = self + .grids + .iter() + .filter_map(|(grid_entity, grid)| { + if let Some(level_entity) = self.current_level.0 { + if self + .child_of + .get(grid_entity) + .is_ok_and(|co| co.parent() == level_entity) + { + Some(grid) + } else { + None + } + } else { + None + } + }) + .next(); + ui.horizontal(|ui| { + ui.label("Cell Size: "); + // The button + slider combination help to indicate that cell size + // requires initialization else grid is empty. These also differ + // from those in the occupancy widget, as those do not ignore mobile + // robots in calculation. However the cell size param used is + // consistent, so any updated value will reflect accordingly + ui.add( + DragValue::new(&mut self.occupancy_display.cell_size) + .range(0.1..=1.0) + .suffix(" m") + .speed(0.01), + ) + .on_hover_text("Slide to calculate occupancy without robots"); + if ui + .button("Calculate Occupancy") + .on_hover_text("Click to calculate occupancy without robots") + .clicked() + { + self.calculate_grid.write(CalculateGrid { + cell_size: self.occupancy_display.cell_size, + ignore: self.robots.iter().collect(), + ..default() + }); + } + }); + ui.horizontal(|ui| { + ui.label("Queue Length Limit: "); + ui.add( + DragValue::new(&mut self.negotiation_params.queue_length_limit) + .range(0..=std::usize::MAX) + .speed(1000), + ); + }); + ui.label("Occupancy"); + ui.indent("occupancy_grid_info", |ui| { + if let Some(grid) = occupancy_grid { + EguiGrid::new("occupancy_map_info") + .num_columns(2) + .show(ui, |ui| { + ui.label("Range"); + ui.label(format!("{:?}", grid.range.min_cell())); + ui.end_row(); + ui.label("Max Cell"); + ui.label(format!("{:?}", grid.range.max_cell())); + ui.end_row(); + ui.label("Dimension"); + ui.label(format!( + "{} x {}", + grid.range.max_cell().x - grid.range.min_cell().x, + grid.range.max_cell().y - grid.range.min_cell().y + )); + ui.end_row(); + }); + } else { + ui.label("None"); + } + }); + // Generate Plan + ui.horizontal(|ui| { + let allow_generate_plan = num_tasks > 0 + && self.negotiation_params.queue_length_limit > 0 + && !self.negotiation_task.status.is_in_progress(); + + ui.add_enabled_ui(allow_generate_plan, |ui| { + if ui.button("Generate Plan").clicked() { + if occupancy_grid.is_none() { + self.calculate_grid.write(CalculateGrid { + cell_size: self.occupancy_display.cell_size, + ignore: self.robots.iter().collect(), + ..default() + }); + } + self.negotiation_request.write(NegotiationRequest); + } + }); + }); + + // Results + ui.separator(); + match &self.negotiation_task.status { + NegotiationTaskStatus::Complete { + elapsed_time, + solution: _, + negotiation_history, + entity_id_map: _, + error_message, + conflicting_endpoints, + } => { + EguiGrid::new("negotiation_data") + .num_columns(2) + .show(ui, |ui| { + ui.label("Execution Time"); + ui.label(format!("{:.2} s", elapsed_time.as_secs_f32())); + ui.end_row(); + ui.label("Negotiation History"); + ui.label(format!("{}", negotiation_history.len())); + ui.end_row(); + ui.label("Endpoint Conflicts"); + ui.label(format!("{}", conflicting_endpoints.len())); + ui.end_row(); + ui.label("Error Message"); + ui.label(error_message.clone().unwrap_or("None".to_string())); + }); + } + NegotiationTaskStatus::InProgress { start_time } => { + let elapsed_time = start_time.elapsed(); + ui.label(format!("In Progress: {}", elapsed_time.as_secs_f32())); + } + _ => {} + } + } +} diff --git a/crates/rmf_site_editor/src/mapf_rse/debug_panel.rs b/crates/rmf_site_editor/src/mapf_rse/debug_panel.rs index 7e772123..d79647a7 100644 --- a/crates/rmf_site_editor/src/mapf_rse/debug_panel.rs +++ b/crates/rmf_site_editor/src/mapf_rse/debug_panel.rs @@ -85,7 +85,7 @@ pub struct NegotiationDebugWidget<'w, 's> { negotiation_debug_data: ResMut<'w, NegotiationDebugData>, negotiation_params: ResMut<'w, NegotiationParams>, negotiation_request: EventWriter<'w, NegotiationRequest>, - tasks: Query<'w, 's, &'static Task>, + tasks: Query<'w, 's, &'static Task>, grids: Query<'w, 's, (Entity, &'static Grid)>, current_level: Res<'w, CurrentLevel>, child_of: Query<'w, 's, &'static ChildOf>, @@ -252,7 +252,7 @@ impl<'w, 's> NegotiationDebugWidget<'w, 's> { fn show_gotoplace_tasks(&mut self, ui: &mut Ui) { let tasks = self.tasks.iter().filter(|task| { - if task.request().category() == GoToPlace::label() { + if task.request().category() == GoToPlace::::label() { true } else { false @@ -262,7 +262,14 @@ impl<'w, 's> NegotiationDebugWidget<'w, 's> { for task in tasks { ui.separator(); ui.label(format!("Task {}", num_tasks)); - ui.label(format!("Robot name - {}", task.robot())); + ui.label(format!( + "Robot name - {}", + task.robot() + .0 + .and_then(|e| self.robots.get(e).ok()) + .map(|(_, name, _)| name.0.clone()) + .unwrap_or("No Robot".to_string()) + )); ui.label(format!("Description - {}", task.request().description())); num_tasks += 1; } diff --git a/crates/rmf_site_editor/src/mapf_rse/negotiation/mod.rs b/crates/rmf_site_editor/src/mapf_rse/negotiation/mod.rs new file mode 100644 index 00000000..85dca6a2 --- /dev/null +++ b/crates/rmf_site_editor/src/mapf_rse/negotiation/mod.rs @@ -0,0 +1,347 @@ +/* + * Copyright (C) 2024 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 bevy::{ + ecs::hierarchy::ChildOf, + prelude::*, + tasks::{futures::check_ready, Task, TaskPool}, +}; +use std::{ + collections::{BTreeMap, HashMap}, + fmt::Debug, + time::{Duration, Instant}, +}; + +use crate::{ + occupancy::{Cell, Grid}, + site::{ + Affiliation, CircleCollision, CurrentLevel, DifferentialDrive, GoToPlace, Group, + LocationTags, ModelMarker, Point, Pose, Robot, Task as RobotTask, + }, +}; +use mapf::negotiation::*; + +use mapf::negotiation::{Agent, Obstacle, Scenario as MapfScenario}; + +pub mod debug_panel; +pub use debug_panel::*; + +pub mod visual; +pub use visual::*; + +#[derive(Default)] +pub struct NegotiationPlugin; + +impl Plugin for NegotiationPlugin { + fn build(&self, app: &mut App) { + app.add_event::() + .init_resource::() + .init_resource::() + .add_plugins(NegotiationDebugPlugin::default()) + .add_systems( + Update, + ( + start_compute_negotiation, + handle_compute_negotiation_complete, + visualise_selected_node, + ), + ); + } +} + +#[derive(Event)] +pub struct NegotiationRequest; + +#[derive(Debug, Clone, Resource)] +pub struct NegotiationParams { + pub queue_length_limit: usize, +} + +impl Default for NegotiationParams { + fn default() -> Self { + Self { + queue_length_limit: 1_000_000, + } + } +} + +#[derive(Debug, Default, Clone)] +pub enum NegotiationTaskStatus { + #[default] + NotStarted, + InProgress { + start_time: Instant, + }, + Complete { + elapsed_time: Duration, + solution: Option, + negotiation_history: Vec, + entity_id_map: HashMap, + error_message: Option, + conflicting_endpoints: Vec<(Entity, Entity)>, + }, +} + +impl NegotiationTaskStatus { + pub fn is_in_progress(&self) -> bool { + matches!(self, NegotiationTaskStatus::InProgress { .. }) + } +} + +#[derive(Debug, Resource)] +pub struct NegotiationTask { + task: Task< + Result< + ( + NegotiationNode, + Vec, + HashMap, + ), + NegotiationError, + >, + >, + pub status: NegotiationTaskStatus, +} + +impl Default for NegotiationTask { + fn default() -> Self { + Self { + task: TaskPool::new().spawn_local(async move { + Err(NegotiationError::PlanningImpossible( + "Not started yet".into(), + )) + }), + status: NegotiationTaskStatus::NotStarted, + } + } +} + +#[derive(Resource)] +pub struct NegotiationDebugData { + pub show_debug_panel: bool, + pub selected_negotiation_node: Option, + pub visualize_keys: bool, + pub visualize_conflicts: bool, + pub visualize_trajectories: bool, +} + +impl Default for NegotiationDebugData { + fn default() -> Self { + Self { + show_debug_panel: false, + selected_negotiation_node: None, + visualize_keys: false, + visualize_conflicts: true, + visualize_trajectories: true, + } + } +} + +pub fn handle_compute_negotiation_complete( + mut negotiation_debug_data: ResMut, + mut negotiation_task: ResMut, +) { + fn bits_string_to_entity(bits_string: &str) -> Entity { + // SAFETY: This assumes function input bits_string to be output from entity.to_bits().to_string() + // Currently, this is fetched from start_compute_negotiation fn, e.g. the key of BTreeMap in scenario.agents + let bits = u64::from_str_radix(bits_string, 10).expect("Invalid entity id"); + Entity::from_bits(bits) + } + + let NegotiationTaskStatus::InProgress { start_time } = negotiation_task.status else { + return; + }; + + if let Some(result) = check_ready(&mut negotiation_task.task) { + let elapsed_time = start_time.elapsed(); + + match result { + Ok((solution, negotiation_history, name_map)) => { + negotiation_debug_data.selected_negotiation_node = Some(solution.id); + negotiation_task.status = NegotiationTaskStatus::Complete { + elapsed_time, + solution: Some(solution), + negotiation_history, + entity_id_map: name_map + .into_iter() + .map(|(id, bits_string)| (id, bits_string_to_entity(&bits_string))) + .collect(), + error_message: None, + conflicting_endpoints: Vec::new(), + }; + } + Err(err) => { + let mut negotiation_history = Vec::new(); + let mut entity_id_map = HashMap::new(); + let mut err_msg = Some(err.to_string()); + let mut conflicts = Vec::new(); + + match err { + NegotiationError::PlanningImpossible(msg) => { + if let Some(err_str) = err_msg { + err_msg = Some([err_str, msg].join(" ")); + } + } + NegotiationError::ConflictingEndpoints(conflicts_map) => { + conflicts = conflicts_map + .into_iter() + .map(|(a, b)| (bits_string_to_entity(&a), bits_string_to_entity(&b))) + .collect(); + } + NegotiationError::PlanningFailed((neg_history, name_map)) => { + negotiation_history = neg_history; + entity_id_map = name_map + .into_iter() + .map(|(id, bits_string)| (id, bits_string_to_entity(&bits_string))) + .collect(); + } + } + + negotiation_task.status = NegotiationTaskStatus::Complete { + elapsed_time: elapsed_time, + solution: None, + negotiation_history: negotiation_history, + entity_id_map: entity_id_map, + error_message: err_msg, + conflicting_endpoints: conflicts, + }; + } + }; + } +} + +pub fn start_compute_negotiation( + locations: Query<&Point, With>, + anchors: Query<&GlobalTransform>, + negotiation_request: EventReader, + negotiation_params: Res, + mut negotiation_debug_data: ResMut, + current_level: Res, + grids: Query<(Entity, &Grid)>, + child_of: Query<&ChildOf>, + robots: Query<(Entity, &Pose, &Affiliation), With>, + robot_descriptions: Query<(&DifferentialDrive, &CircleCollision)>, + tasks: Query<(&RobotTask, &GoToPlace)>, + mut negotiation_task: ResMut, +) { + if negotiation_request.len() == 0 { + return; + } + + if negotiation_task.status.is_in_progress() { + warn!("Negotiation requested while another negotiation is in progress"); + return; + } + + negotiation_debug_data.selected_negotiation_node = None; + + // Occupancy + let mut occupancy = HashMap::>::new(); + let mut cell_size = 1.0; + let grid = grids.iter().find_map(|(grid_entity, grid)| { + if let Some(level_entity) = current_level.0 { + if child_of + .get(grid_entity) + .is_ok_and(|co| co.parent() == level_entity) + { + Some(grid) + } else { + None + } + } else { + None + } + }); + match grid { + Some(grid) => { + cell_size = grid.cell_size; + for cell in grid.occupied.iter() { + occupancy.entry(cell.y).or_default().push(cell.x); + } + for (_, column) in &mut occupancy { + column.sort_unstable(); + } + } + None => { + occupancy.entry(0).or_default().push(0); + warn!("No occupancy grid found, defaulting to empty"); + } + } + + // Agent + let mut agents = BTreeMap::::new(); + // Only loop tasks that have specified a valid robot + for (task, go_to_place) in tasks.iter() { + for (robot_entity, robot_pose, robot_group) in robots.iter() { + if task.robot().0.is_some_and(|e| robot_entity == e) { + // Match location to entity + for Point(anchor_entity) in locations.iter() { + if go_to_place.location.0.is_some_and(|e| e == *anchor_entity) { + let Ok(goal_transform) = anchors.get(*anchor_entity) else { + warn!("Unable to get robot's goal transform"); + continue; + }; + let Some((differential_drive, circle_collision)) = + robot_group.0.and_then(|e| robot_descriptions.get(e).ok()) + else { + warn!("Unable to get robot's collision model"); + continue; + }; + let goal_pos = goal_transform.translation(); + let agent = Agent { + start: to_cell(robot_pose.trans[0], robot_pose.trans[1], cell_size), + yaw: f64::from(robot_pose.rot.yaw().radians()), + goal: to_cell(goal_pos.x, goal_pos.y, cell_size), + radius: f64::from(circle_collision.radius), + speed: f64::from(differential_drive.translational_speed), + spin: f64::from(differential_drive.rotational_speed), + }; + let agent_id = robot_entity.to_bits().to_string(); + agents.insert(agent_id, agent); + break; + } + } + break; + } + } + } + + if agents.len() == 0 { + warn!("No agents with valid GoToPlace task"); + return; + } + + let scenario = MapfScenario { + agents: agents, + obstacles: Vec::::new(), + occupancy: occupancy, + cell_size: f64::from(cell_size), + camera_bounds: None, + }; + let queue_length_limit = negotiation_params.queue_length_limit; + + // Execute asynchronously + let start_time = Instant::now(); + negotiation_task.status = NegotiationTaskStatus::InProgress { start_time }; + negotiation_task.task = + TaskPool::new().spawn_local(async move { negotiate(&scenario, Some(queue_length_limit)) }); +} + +fn to_cell(x: f32, y: f32, cell_size: f32) -> [i64; 2] { + let cell = Cell::from_point(Vec2::new(x, y), cell_size); + [cell.x, cell.y] +} diff --git a/crates/rmf_site_editor/src/site/inclusion.rs b/crates/rmf_site_editor/src/site/inclusion.rs index 69306dd0..5222b56b 100644 --- a/crates/rmf_site_editor/src/site/inclusion.rs +++ b/crates/rmf_site_editor/src/site/inclusion.rs @@ -31,9 +31,12 @@ impl Property for Inclusion { fn on_new_element( for_element: Entity, in_scenario: Entity, - _value: Inclusion, + value: Inclusion, world: &mut World, ) { + // Insert inclusion modifier with given value into target scenario + world.trigger(UpdateModifier::modify(in_scenario, for_element, value)); + let mut scenario_state: SystemState< Query<(Entity, &ScenarioModifiers, &Affiliation)>, > = SystemState::new(world); diff --git a/crates/rmf_site_editor/src/site/load.rs b/crates/rmf_site_editor/src/site/load.rs index 0f499891..8199a6e3 100644 --- a/crates/rmf_site_editor/src/site/load.rs +++ b/crates/rmf_site_editor/src/site/load.rs @@ -691,8 +691,11 @@ fn generate_site_entities( } for (task_id, task_data) in &site_data.tasks { + let task = task_data + .convert(&id_to_entity) + .as_broken_error(site_id, "task")?; let task_entity = commands - .spawn(task_data.clone()) + .spawn(task.clone()) .insert(SiteID(*task_id)) .insert(Category::Task) .insert(ChildOf(site_id)) diff --git a/crates/rmf_site_editor/src/site/mod.rs b/crates/rmf_site_editor/src/site/mod.rs index 09655962..5c981f57 100644 --- a/crates/rmf_site_editor/src/site/mod.rs +++ b/crates/rmf_site_editor/src/site/mod.rs @@ -305,11 +305,11 @@ impl Plugin for SitePlugin { ChangePlugin::>::default(), ChangePlugin::>::default(), ChangePlugin::>::default(), - ChangePlugin::::default(), + ChangePlugin::>::default(), PropertyPlugin::::default(), PropertyPlugin::::default(), - PropertyPlugin::::default(), - PropertyPlugin::::default(), + PropertyPlugin::>::default(), + PropertyPlugin::>::default(), PropertyPlugin::, Robot>::default(), SlotcarSdfPlugin, MaterialPlugin::>::default(), @@ -478,6 +478,7 @@ impl Plugin for SitePlugin { check_for_missing_root_modifiers::, update_default_scenario, update_lane_motion_visuals, + update_direct_task_fleet, ) .run_if(AppState::in_displaying_mode()) .in_set(SiteUpdateSet::BetweenTransformAndVisibility), diff --git a/crates/rmf_site_editor/src/site/save.rs b/crates/rmf_site_editor/src/site/save.rs index 2eb8583d..a5845784 100644 --- a/crates/rmf_site_editor/src/site/save.rs +++ b/crates/rmf_site_editor/src/site/save.rs @@ -145,7 +145,7 @@ fn assign_site_ids(world: &mut World, site: Entity) -> Result<(), SiteGeneration Query, With)>, Query, Without, Without)>, Query<(Entity, &Affiliation), With>>, - Query, Without)>, + Query>, Without)>, Query< Entity, ( @@ -1654,15 +1654,37 @@ fn generate_scenarios( fn generate_tasks( site: Entity, world: &mut World, -) -> Result, SiteGenerationError> { - let mut state: SystemState<(Query<(&SiteID, &Task), Without>, Query<&Children>)> = - SystemState::new(world); - let (tasks, children) = state.get(world); - let mut res = BTreeMap::::new(); +) -> Result>, SiteGenerationError> { + let mut state: SystemState<( + Query<(Entity, &SiteID, &Task), Without>, + Query<&SiteID, (With, Without)>, + Query<&Children>, + )> = SystemState::new(world); + let (tasks, robots, children) = state.get(world); + let mut res = BTreeMap::>::new(); if let Ok(children) = children.get(site) { for child in children.iter() { - if let Ok((site_id, task)) = tasks.get(child) { - res.insert(site_id.0, task.clone()); + if let Ok((task_entity, site_id, task)) = tasks.get(child) { + let task = match task { + Task::Dispatch(request) => Task::Dispatch(request.clone()), + Task::Direct(request) => { + let robot_entity = match request.robot.0 { + Some(e) => e, + None => return Err(SiteGenerationError::EmptyAffiliation(task_entity)), + }; + Task::Direct(RobotTaskRequest::new( + match robots.get(robot_entity) { + Ok(id) => Affiliation(Some(id.0)), + Err(_) => { + return Err(SiteGenerationError::MissingSiteID(robot_entity)) + } + }, + request.fleet.clone(), + request.request.clone(), + )) + } + }; + res.insert(site_id.0, task); } } } diff --git a/crates/rmf_site_editor/src/site/task.rs b/crates/rmf_site_editor/src/site/task.rs index 62f2b657..14f016c7 100644 --- a/crates/rmf_site_editor/src/site/task.rs +++ b/crates/rmf_site_editor/src/site/task.rs @@ -15,15 +15,18 @@ * */ -use crate::site::{Element, StandardProperty, Task, TaskKind, TaskParams}; +use crate::site::{ + Element, Group, ModelMarker, Robot, StandardProperty, Task, TaskKind, TaskParams, +}; use bevy::prelude::*; use std::collections::HashMap; pub type InsertTaskKindFn = fn(EntityCommands); pub type RemoveTaskKindFn = fn(EntityCommands); +pub type IsTaskValidFn = fn(Entity, &mut World) -> bool; #[derive(Resource)] -pub struct TaskKinds(pub HashMap); +pub struct TaskKinds(pub HashMap); impl FromWorld for TaskKinds { fn from_world(_world: &mut World) -> Self { @@ -31,13 +34,13 @@ impl FromWorld for TaskKinds { } } -impl Element for Task {} +impl Element for Task {} impl StandardProperty for TaskParams {} pub fn update_task_kind_component( mut commands: Commands, - tasks: Query<(Entity, Ref, Option<&T>)>, + tasks: Query<(Entity, Ref>, Option<&T>)>, ) { for (entity, task, task_kind) in tasks.iter() { if task.is_changed() { @@ -53,3 +56,26 @@ pub fn update_task_kind_component( } } } + +// This systems monitors for changes in a Robot's fleet and updates relevant +// RobotTaskRequests accordingly +// TODO(@xiyuoh) This does not update fleet name for DispatchTasks, since they +// are not tagged to any robot. Convert fleet name to its own component so that +// we can track non-direct task fleet name changes too. +pub fn update_direct_task_fleet( + robots: Query<(Entity, Ref), (With, Without)>, + mut tasks: Query<&mut Task>, +) { + for (entity, robot) in robots.iter() { + if robot.is_changed() { + for mut task in tasks.iter_mut() { + if task.robot().0.is_some_and(|e| e == entity) && task.fleet() != robot.fleet { + // Update fleet name if it has changed + if let Some(fleet) = task.fleet_mut() { + *fleet = robot.fleet.clone(); + } + } + } + } + } +} diff --git a/crates/rmf_site_editor/src/widgets/creation.rs b/crates/rmf_site_editor/src/widgets/creation.rs index 007063d3..19a2ca20 100644 --- a/crates/rmf_site_editor/src/widgets/creation.rs +++ b/crates/rmf_site_editor/src/widgets/creation.rs @@ -22,7 +22,9 @@ use crate::{ IsStatic, Members, ModelDescriptionBundle, ModelInstance, ModelMarker, ModelProperty, NameInSite, Recall, RecallAssetSource, Scale, }, - widgets::{AssetGalleryStatus, Icons, InspectAssetSourceComponent, InspectScaleComponent}, + widgets::{ + AssetGalleryStatus, Icons, InspectAssetSourceComponent, InspectScaleComponent, TaskWidget, + }, AppState, CurrentWorkspace, }; @@ -52,6 +54,7 @@ impl Plugin for StandardCreationPlugin { DrawingCreationPlugin::default(), ModelCreationPlugin::default(), BrowseFuelTogglePlugin::default(), + TaskPanelTogglePlugin::default(), )); } } @@ -670,6 +673,50 @@ impl<'w> WidgetSystem for BrowseFuelToggle<'w> { } } +#[derive(Default)] +pub struct TaskPanelTogglePlugin {} + +impl Plugin for TaskPanelTogglePlugin { + fn build(&self, app: &mut App) { + app.add_plugins(HeaderTilePlugin::::new()); + } +} + +#[derive(SystemParam)] +pub struct TaskPanelToggle<'w> { + task_widget: Option>, + app_state: Res<'w, State>, +} + +impl<'w> WidgetSystem for TaskPanelToggle<'w> { + fn show(_: Tile, ui: &mut Ui, state: &mut SystemState, world: &mut World) -> () { + let mut params = state.get_mut(world); + if !matches!(params.app_state.get(), AppState::SiteEditor) { + return; + } + + let enabled = params.task_widget.is_some(); + let toggled_on = params.task_widget.as_ref().is_some_and(|panel| panel.show); + let tooltip = if !enabled { + "Task panel is not available" + } else if toggled_on { + "Close task panel" + } else { + "Open task panel" + }; + + if ui + .add_enabled(enabled, Button::new("📋").selected(toggled_on)) + .on_hover_text(tooltip) + .clicked() + { + if let Some(panel) = &mut params.task_widget { + panel.show = !panel.show; + } + } + } +} + /// Helper funtion to display the button name on hover fn button_clicked(ui: &mut Ui, icon: &str, tooltip: &str) -> bool { ui.add(Button::new(icon)).on_hover_text(tooltip).clicked() diff --git a/crates/rmf_site_editor/src/widgets/inspector/inspect_model_description/inspect_robot_properties.rs b/crates/rmf_site_editor/src/widgets/inspector/inspect_model_description/inspect_robot_properties.rs index 2d8503e7..a7b174dc 100644 --- a/crates/rmf_site_editor/src/widgets/inspector/inspect_model_description/inspect_robot_properties.rs +++ b/crates/rmf_site_editor/src/widgets/inspector/inspect_model_description/inspect_robot_properties.rs @@ -25,7 +25,7 @@ use crate::{ AppState, Issue, ValidateWorkspace, }; use bevy::ecs::{hierarchy::ChildOf, system::SystemParam}; -use bevy_egui::egui::{ComboBox, Ui}; +use bevy_egui::egui::{ComboBox, TextEdit, Ui}; use rmf_site_format::robot_properties::*; use serde_json::Value; use smallvec::SmallVec; @@ -72,7 +72,7 @@ impl RobotPropertiesInspector { struct InspectRobotProperties<'w, 's> { model_instances: ModelPropertyQuery<'w, 's, Robot>, model_descriptions: - Query<'w, 's, &'static ModelProperty, (With, With)>, + Query<'w, 's, &'static mut ModelProperty, (With, With)>, inspect_robot_properties: Res<'w, RobotPropertiesInspector>, children: Query<'w, 's, &'static Children>, } @@ -88,7 +88,7 @@ impl<'w, 's> WidgetSystem for InspectRobotProperties<'w, 's> { state: &mut SystemState, world: &mut World, ) { - let params = state.get_mut(world); + let mut params = state.get_mut(world); let Some(description_entity) = get_selected_description_entity( selection, ¶ms.model_instances, @@ -97,9 +97,18 @@ impl<'w, 's> WidgetSystem for InspectRobotProperties<'w, 's> { return; }; // Ensure that this widget is displayed only when there is a valid Robot property - let Ok(ModelProperty(_robot)) = params.model_descriptions.get(description_entity) else { + let Ok(mut robot_model_property) = params.model_descriptions.get_mut(description_entity) + else { return; }; + + ui.horizontal(|ui| { + ui.label("Fleet:"); + TextEdit::singleline(&mut robot_model_property.0.fleet) + .desired_width(ui.available_width()) + .show(ui); + }); + ui.label("Robot Properties"); let children: Result, _> = params diff --git a/crates/rmf_site_editor/src/widgets/tasks/go_to_place.rs b/crates/rmf_site_editor/src/widgets/tasks/go_to_place.rs index 164ff72c..0ab97e84 100644 --- a/crates/rmf_site_editor/src/widgets/tasks/go_to_place.rs +++ b/crates/rmf_site_editor/src/widgets/tasks/go_to_place.rs @@ -17,7 +17,10 @@ use super::{EditTask, TaskWidget}; use crate::{ mapf_rse::DebugGoal, - site::{update_task_kind_component, LocationTags, NameInSite, Task, TaskKind, TaskKinds}, + site::{ + update_task_kind_component, Affiliation, LocationTags, NameInSite, SiteID, Task, TaskKind, + TaskKinds, + }, widgets::prelude::*, }; use bevy::{ @@ -27,9 +30,10 @@ use bevy::{ }, prelude::*, }; -use bevy_egui::egui::ComboBox; +use bevy_egui::egui::{ComboBox, SelectableLabel}; use rmf_site_egui::*; use rmf_site_format::{GoToPlace, Robot}; +use rmf_site_picking::Hover; #[derive(Default)] pub struct GoToPlacePlugin {} @@ -37,30 +41,46 @@ pub struct GoToPlacePlugin {} impl Plugin for GoToPlacePlugin { fn build(&self, app: &mut App) { app.world_mut().resource_mut::().0.insert( - GoToPlace::label(), + GoToPlace::::label(), ( |mut e_cmd| { - e_cmd.insert(GoToPlace::default()); + e_cmd.insert(GoToPlace::::default()); }, |mut e_cmd| { - e_cmd.remove::(); + e_cmd.remove::>(); + }, + |e, world| { + let Some(loc_entity) = world + .entity(e) + .get::>() + .and_then(|go_to_place| go_to_place.location.0) + else { + return false; + }; + let mut state: SystemState>> = + SystemState::new(world); + let locations = state.get(world); + + locations.get(loc_entity).is_ok() }, ), ); let widget = Widget::::new::(&mut app.world_mut()); let task_widget = app.world().resource::().get(); app.world_mut().spawn(widget).insert(ChildOf(task_widget)); - app.add_systems(PostUpdate, update_task_kind_component::); + app.add_systems(PostUpdate, update_task_kind_component::>) + .add_observer(on_load_go_to_place); } } #[derive(SystemParam)] pub struct ViewGoToPlace<'w, 's> { - locations: Query<'w, 's, (Entity, &'static NameInSite), With>, + locations: + Query<'w, 's, (Entity, &'static NameInSite, Option<&'static SiteID>), With>, edit_task: Res<'w, EditTask>, - tasks: Query<'w, 's, (&'static mut GoToPlace, &'static mut Task)>, - robot_debug_goal: - Query<'w, 's, (Entity, &'static NameInSite, Option<&'static mut DebugGoal>), With>, + tasks: Query<'w, 's, (&'static mut GoToPlace, &'static mut Task)>, + hover: EventWriter<'w, Hover>, + robot_debug_goal: Query<'w, 's, (Entity, Option<&'static mut DebugGoal>), With>, commands: Commands<'w, 's>, } @@ -78,15 +98,14 @@ impl<'w, 's> WidgetSystem for ViewGoToPlace<'w, 's> { return; } - let selected_location_name = if go_to_place.location.is_empty() - || !params - .locations - .iter() - .any(|l| l.1 .0 == go_to_place.location) + let selected_location_name = if let Some((_, loc_name, _)) = go_to_place + .location + .0 + .and_then(|e| params.locations.get(e).ok()) { - "Select Location".to_string() + loc_name.0.clone() } else { - go_to_place.location.clone() + "Select Location".to_string() }; let mut new_go_to_place = go_to_place.clone(); @@ -95,53 +114,106 @@ impl<'w, 's> WidgetSystem for ViewGoToPlace<'w, 's> { ComboBox::from_id_salt("select_go_to_location") .selected_text(selected_location_name) .show_ui(ui, |ui| { - for (_, location_name) in params.locations.iter() { - ui.selectable_value( - &mut new_go_to_place.location, - location_name.0.clone(), - location_name.0.clone(), - ); + // Sort locations alphabetically + let mut sorted_locations = params.locations.iter().fold( + Vec::<(Entity, String)>::new(), + |mut l, (e, name, _)| { + l.push((e, name.0.clone())); + l + }, + ); + sorted_locations.sort_by(|a, b| a.1.cmp(&b.1)); + for (loc_entity, loc_name) in sorted_locations.iter() { + let resp = ui.add(SelectableLabel::new( + new_go_to_place.location == Affiliation(Some(*loc_entity)), + loc_name.clone(), + )); + if resp.clicked() { + new_go_to_place.location = Affiliation(Some(*loc_entity)); + } else if resp.hovered() { + params.hover.write(Hover(Some(*loc_entity))); + } } }); }); if *go_to_place != new_go_to_place { *go_to_place = new_go_to_place.clone(); - let task_robot_name = task.robot(); - // TODO(Nielsen): Save location as entity in task and robot as entity in task to avoid iterating - for (robot_entity, robot_name, debug_goal) in params.robot_debug_goal.iter_mut() { - if robot_name.0 == task_robot_name { - let location = go_to_place.location.clone(); - let mut entity = None; - if let Some((location_entity, _)) = params - .locations - .iter() - .find(|(_, location_name)| location_name.0 == go_to_place.location) - { - entity = Some(location_entity); - } else { - error!("Unable to find location entity from name"); - } + let location_entity = new_go_to_place.location.0; + let location_name = location_entity + .and_then(|e| params.locations.get(e).ok()) + .map(|(_, name, _)| name.0.clone()); - if let Some(mut goal) = debug_goal { - goal.location = go_to_place.location.clone(); - goal.entity = entity; - } else { - params - .commands - .entity(robot_entity) - .insert(DebugGoal { location, entity }); - } - break; - } + // Convert Location entity to SiteID before serializing + if let Some(description) = location_entity + .and_then(|e| params.locations.get(e).ok()) + .and_then(|(_, _, site_id)| site_id) + .and_then(|id| { + serde_json::to_value(GoToPlace:: { + location: Affiliation(Some(id.0)), + }) + .ok() + }) + { + *task.request_mut().description_mut() = description; } + *task.request_mut().description_display_mut() = location_name.clone(); - if let Ok(description) = serde_json::to_value(new_go_to_place.clone()) { - *task.request_mut().description_mut() = description; - *task.request_mut().description_display_mut() = - Some(format!("{}", new_go_to_place.clone())); + // Update DebugGoal + // TODO(@xiyuoh) debug why negotiation loops if goal changes for existing task + if let Some((robot_entity, debug_goal)) = task + .robot() + .0 + .and_then(|e_robot| params.robot_debug_goal.get_mut(e_robot).ok()) + { + let goal_location = location_name.unwrap_or("No Location".to_string()); + if let Some(mut goal) = debug_goal { + goal.location = goal_location; + goal.entity = location_entity; + } else { + params.commands.entity(robot_entity).insert(DebugGoal { + location: goal_location, + entity: location_entity, + }); + } } } } } + +/// When loading a GoToPlace task from file, locations are stored as SiteID. +/// Since task description is serialized as JSON, we won't be able to do the +/// usual Entity <-> SiteID conversion. This observer checks that the GoToPlace +/// task/location entity loaded is valid. If not, use location name stored in +/// description display to select location entity. +fn on_load_go_to_place( + trigger: Trigger>, + mut commands: Commands, + tasks: Query<(Entity, &Task, Option<&GoToPlace>)>, + locations: Query<(Entity, &NameInSite), With>, +) { + let Ok((task_entity, task, go_to_place)) = tasks.get(trigger.target()) else { + return; + }; + if task.request().category() != GoToPlace::::label() { + return; + } + // Ignore if this is a valid location entity + if go_to_place.is_some_and(|gtp| gtp.location.0.is_some_and(|e| locations.get(e).is_ok())) { + return; + } + + // Rely on description display for location name matching + let Some(location_name) = task.request().description_display() else { + return; + }; + for (entity, name) in locations.iter() { + if location_name == *name.0 { + commands.entity(task_entity).insert(GoToPlace:: { + location: Affiliation(Some(entity)), + }); + return; + } + } +} diff --git a/crates/rmf_site_editor/src/widgets/tasks/mod.rs b/crates/rmf_site_editor/src/widgets/tasks/mod.rs index ee085091..58934218 100644 --- a/crates/rmf_site_editor/src/widgets/tasks/mod.rs +++ b/crates/rmf_site_editor/src/widgets/tasks/mod.rs @@ -19,22 +19,23 @@ use crate::{ count_scenarios_with_inclusion, Affiliation, Category, Change, CurrentScenario, Delete, DispatchTaskRequest, GetModifier, Group, Inclusion, Modifier, NameInSite, Pending, Robot, RobotTaskRequest, ScenarioModifiers, SiteUpdateSet, Task, TaskKinds, TaskParams, - UpdateModifier, + TaskRequest, UpdateModifier, }, AppState, CurrentWorkspace, Icons, }; use bevy::{ - ecs::{ - hierarchy::ChildOf, - system::{SystemParam, SystemState}, - }, + ecs::system::{SystemParam, SystemState}, prelude::*, }; -use bevy_egui::egui::{ - Align, CollapsingHeader, Color32, ComboBox, DragValue, Frame, Grid, ImageButton, Layout, - Stroke, TextEdit, Ui, +use bevy_egui::{ + egui::{ + Align, Align2, CollapsingHeader, Color32, ComboBox, DragValue, Frame, Grid, ImageButton, + Layout, RichText, ScrollArea, SelectableLabel, Stroke, TextEdit, Ui, Window, + }, + EguiContexts, }; use rmf_site_egui::*; +use rmf_site_picking::Hover; use serde_json::Value; use smallvec::SmallVec; @@ -63,7 +64,9 @@ impl Plugin for MainTasksPlugin { app.init_resource::() .init_resource::() .init_resource::() + .init_resource::() .add_event::() + .add_systems(Update, show_create_task_dialog) .add_systems( PostUpdate, handle_task_edit @@ -76,7 +79,8 @@ impl Plugin for MainTasksPlugin { /// Contains a reference to the tasks widget. #[derive(Resource)] pub struct TaskWidget { - id: Entity, + pub id: Entity, + pub show: bool, } impl TaskWidget { @@ -85,15 +89,35 @@ impl TaskWidget { } } +#[derive(Resource, Default)] +pub struct CreateTaskDialog { + pub visible: bool, +} + impl FromWorld for TaskWidget { fn from_world(world: &mut World) -> Self { - let widget = Widget::new::(world); - let properties_panel = world.resource::().id(); - let id = world.spawn(widget).insert(ChildOf(properties_panel)).id(); - Self { id } + let panel_widget = PanelWidget::new(tasks_panel, world); + let panel_id = world.spawn((panel_widget, PanelSide::Left)).id(); + + let main_task_widget = Widget::new::(world); + let id = world.spawn(main_task_widget).insert(ChildOf(panel_id)).id(); + + Self { id, show: false } } } +fn tasks_panel(In(PanelWidgetInput { id, context }): In, world: &mut World) { + let correct_state = world + .get_resource::>() + .is_some_and(|state| matches!(state.get(), AppState::SiteEditor)); + + if !world.resource::().show || !correct_state { + return; + } + + show_panel_of_tiles(In(PanelWidgetInput { id, context }), world); +} + /// Points to any task entity that is currently in edit mode #[derive(Resource, Deref, DerefMut)] pub struct EditTask(pub Option); @@ -126,9 +150,9 @@ pub struct ViewTasks<'w, 's> { edit_task: Res<'w, EditTask>, get_inclusion_modifier: GetModifier<'w, 's, Modifier>, get_params_modifier: GetModifier<'w, 's, Modifier>, + hover: EventWriter<'w, Hover>, icons: Res<'w, Icons>, - pending_tasks: Query<'w, 's, (Entity, &'static Task, &'static TaskParams), With>, - robots: Query<'w, 's, (Entity, &'static NameInSite), (With, Without)>, + robots: Query<'w, 's, (Entity, &'static NameInSite, &'static Robot), Without>, scenarios: Query< 'w, 's, @@ -140,7 +164,7 @@ pub struct ViewTasks<'w, 's> { >, task_kinds: ResMut<'w, TaskKinds>, task_widget: ResMut<'w, TaskWidget>, - tasks: Query<'w, 's, (Entity, &'static Task), Without>, + tasks: Query<'w, 's, (Entity, &'static Task), Without>, } impl<'w, 's> WidgetSystem for ViewTasks<'w, 's> { @@ -150,37 +174,44 @@ impl<'w, 's> WidgetSystem for ViewTasks<'w, 's> { state: &mut SystemState, world: &mut World, ) { - CollapsingHeader::new("Tasks") - .default_open(true) - .show(ui, |ui| { - let mut params = state.get_mut(world); - params.show_widget(ui); - - if params.edit_task.0.is_some() { - let children: Result, _> = params - .children - .get(params.task_widget.id) - .map(|children| children.iter().collect()); - let Ok(children) = children else { - return; - }; - - for child in children { - let tile = Tile { id, panel }; - let _ = world.try_show_in(child, tile, ui); - } - } - }); + ui.label(RichText::new("Tasks").size(18.0)); ui.add_space(10.0); - } -} + let params = state.get_mut(world); -impl<'w, 's> ViewTasks<'w, 's> { - pub fn show_widget(&mut self, ui: &mut Ui) { - let Some(current_scenario_entity) = self.current_scenario.0 else { + let Some(current_scenario_entity) = params.current_scenario.0 else { ui.label("No scenario selected, unable to display or create tasks."); return; }; + // Tasks are sorted by start time, then request time, then created time, + // depending on which fields are populated + let mut tasks = Vec::<(i64, (Entity, Task))>::new(); + let mut tasks_without_time = Vec::<(i64, (Entity, Task))>::new(); + + for (e, task) in params.tasks.iter() { + if let Some(params_modifier) = + params.get_params_modifier.get(current_scenario_entity, e) + { + if let Some(start_time) = params_modifier.start_time() { + tasks.push((start_time, (e, task.clone()))); + continue; + } + if let Some(request_time) = params_modifier.request_time() { + tasks.push((request_time, (e, task.clone()))); + continue; + } + } + if let Some(created_time) = task.request().created_time() { + tasks.push((created_time, (e, task.clone()))); + continue; + } + // We should not reach here as created_time is populated by default, + // but in case it comes up as None we sort these by entity index and + // place them at the end of the task list + tasks_without_time.push((e.index() as i64, (e, task.clone()))); + } + tasks.sort_by(|a, b| a.0.cmp(&b.0)); + tasks_without_time.sort_by(|a, b| a.0.cmp(&b.0)); + tasks.extend(tasks_without_time); // View and modify tasks in current scenario Frame::default() @@ -189,143 +220,66 @@ impl<'w, 's> ViewTasks<'w, 's> { .stroke(Stroke::new(1.0, Color32::GRAY)) .show(ui, |ui| { ui.set_min_width(ui.available_width()); - for (task_entity, task) in self.tasks.iter() { - let scenario_count = count_scenarios_with_inclusion( - &self.scenarios, - task_entity, - &self.get_inclusion_modifier, - ); - if show_task( - ui, - &mut self.commands, - task_entity, - task, - current_scenario_entity, - &self.get_inclusion_modifier, - &self.get_params_modifier, - &mut self.delete, - scenario_count, - &self.icons, - ) { - self.edit_mode.write(EditModeEvent { - scenario: current_scenario_entity, - mode: EditMode::Edit(Some(task_entity)), + if tasks.is_empty() { + ui.with_layout(Layout::top_down_justified(Align::LEFT), |ui| { + ui.label("No tasks in this scenario"); + }); + } else { + let max_height = ui.available_height() / 2.0; + ScrollArea::new([true, true]) + .max_height(max_height) + .auto_shrink([false, false]) + .show(ui, |ui| { + for (_, (task_entity, task)) in tasks.iter() { + show_task_widget( + ui, + Tile { id, panel }, + world, + state, + current_scenario_entity, + *task_entity, + task, + ); + } }); - } - } - if self.tasks.is_empty() { - ui.label("No tasks in this scenario"); } }); ui.add_space(10.0); ui.separator(); + ui.add_space(10.0); - let mut reset_edit: bool = false; - - if let Some(task_entity) = self.edit_task.0 { - if let Ok((_, pending_task, pending_task_params)) = - self.pending_tasks.get_mut(task_entity) - { - ui.horizontal(|ui| { - ui.label("Creating Task"); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if ui.button("Cancel").clicked() { - reset_edit = true; - } - ui.add_enabled_ui(pending_task.is_valid(), |ui| { - // TODO(@xiyuoh) Also check validity of TaskKind (e.g. GoToPlace) - if ui - .button("Add Task") - .on_hover_text("Add this task to the current scenario") - .clicked() - { - self.commands.entity(task_entity).remove::(); - reset_edit = true; - } - }); - }); - }); - ui.separator(); - edit_task( - ui, - &mut self.commands, - task_entity, - pending_task, - &pending_task_params, - &self.task_kinds, - &self.robots, - ); - } else { - if let Ok((_, existing_task)) = self.tasks.get_mut(task_entity) { - ui.horizontal(|ui| { - ui.label("Editing Task"); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - ui.add_enabled_ui(existing_task.is_valid(), |ui| { - if ui.button("Done").clicked() { - reset_edit = true; - } - }); - }); - }); - let Some(existing_task_params) = self - .get_params_modifier - .get(current_scenario_entity, task_entity) - .map(|modifier| (**modifier).clone()) - else { - return; - }; - ui.separator(); - edit_task( - ui, - &mut self.commands, - task_entity, - existing_task, - &existing_task_params, - &self.task_kinds, - &self.robots, - ); - } - } - } else { + let mut params = state.get_mut(world); + if params.edit_task.0.is_none() { if ui.button("✚ Create New Task").clicked() { - let new_task = self + let new_task = params .commands - .spawn(Task::default()) + .spawn(Task::::default()) .insert(Category::Task) - .insert(TaskParams::default()) + .insert(Inclusion::Included) // New tasks created are included by default .insert(Pending) .id(); - self.edit_mode.write(EditModeEvent { + params.edit_mode.write(EditModeEvent { scenario: current_scenario_entity, mode: EditMode::New(new_task), }); } } - - if reset_edit { - self.edit_mode.write(EditModeEvent { - scenario: current_scenario_entity, - mode: EditMode::Edit(None), - }); - } + ui.add_space(10.0); } } -/// Displays the task data and params and returns a boolean indicating if the user -/// wishes to edit the task. -fn show_task( +fn show_task_widget( ui: &mut Ui, - commands: &mut Commands, - task_entity: Entity, - task: &Task, + Tile { id, panel }: Tile, + world: &mut World, + state: &mut SystemState, scenario: Entity, - get_inclusion_modifier: &GetModifier>, - get_params_modifier: &GetModifier>, - delete: &mut EventWriter, - scenario_count: i32, - icons: &Res, -) -> bool { - let present = get_inclusion_modifier + task_entity: Entity, + task: &Task, +) { + let params = state.get_mut(world); + let present = params + .get_inclusion_modifier .get(scenario, task_entity) .map(|i_modifier| **i_modifier == Inclusion::Included) .unwrap_or(false); @@ -334,7 +288,7 @@ fn show_task( } else { Color32::default() }; - let mut edit = false; + Frame::default() .inner_margin(4.0) .fill(color) @@ -342,465 +296,789 @@ fn show_task( .show(ui, |ui| { ui.set_min_width(ui.available_width()); - ui.horizontal(|ui| { - ui.label("Task ".to_owned() + &task_entity.index().to_string()) // TODO(@xiyuoh) better identifier - .on_hover_text(format!("Task is included in {} scenarios", scenario_count)); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let mut params = state.get_mut(world); + + let in_edit_mode = params.edit_task.0.is_some_and(|e| e == task_entity); + let scenario_count = count_scenarios_with_inclusion( + ¶ms.scenarios, + task_entity, + ¶ms.get_inclusion_modifier, + ); + show_task_params( + ui, + &mut params.commands, + task_entity, + task, + scenario, + ¶ms.get_inclusion_modifier, + ¶ms.get_params_modifier, + &mut params.delete, + &mut params.edit_mode, + &mut params.task_kinds, + ¶ms.robots, + scenario_count, + ¶ms.icons, + present, + in_edit_mode, + &mut params.hover, + ); + + // Display children widgets if editing existing task + if in_edit_mode { + ui.separator(); + let children: Result, _> = params + .children + .get(params.task_widget.id) + .map(|children| children.iter().collect()); + let Ok(children) = children else { + return; + }; + + for child in children { + let tile = Tile { id, panel }; + let _ = world.try_show_in(child, tile, ui); + } + } + }); +} + +/// Displays the task data and params, and allows users to enter edit mode to modify values +fn show_task_params( + ui: &mut Ui, + commands: &mut Commands, + task_entity: Entity, + task: &Task, + scenario: Entity, + get_inclusion_modifier: &GetModifier>, + get_params_modifier: &GetModifier>, + delete: &mut EventWriter, + edit_mode: &mut EventWriter, + task_kinds: &ResMut, + robots: &Query<(Entity, &NameInSite, &Robot), Without>, + scenario_count: i32, + icons: &Res, + present: bool, + in_edit_mode: bool, + hover: &mut EventWriter, +) { + ui.horizontal(|ui| { + ui.label(task.request().id().to_owned()) + .on_hover_text(format!("Task is included in {} scenarios", scenario_count)); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui + .add(ImageButton::new(icons.trash.egui())) + .on_hover_text("Remove task from all scenarios") + .clicked() + { + delete.write(Delete::new(task_entity)); + } + // Include/hide task + // Toggle between 3 inclusion modes: Included -> None (inherit from parent) -> Hidden + // If this is a root scenario, we won't include the None option + let inclusion_modifier = get_inclusion_modifier + .scenarios + .get(scenario) + .ok() + .and_then(|(scenario_modifiers, _)| scenario_modifiers.get(&task_entity)) + .and_then(|e| get_inclusion_modifier.modifiers.get(*e).ok()); + if let Some(inclusion_modifier) = inclusion_modifier { + // Either explicitly included or hidden + if **inclusion_modifier == Inclusion::Hidden { if ui - .add(ImageButton::new(icons.trash.egui())) - .on_hover_text("Remove task from all scenarios") + .add(ImageButton::new(icons.hide.egui())) + .on_hover_text("Task is hidden in this scenario") .clicked() { - delete.write(Delete::new(task_entity)); + commands.entity(task_entity).insert(Inclusion::Included); } - // Include/hide task - // Toggle between 3 inclusion modes: Included -> None (inherit from parent) -> Hidden - // If this is a root scenario, we won't include the None option - let inclusion_modifier = get_inclusion_modifier - .scenarios - .get(scenario) - .ok() - .and_then(|(scenario_modifiers, _)| scenario_modifiers.get(&task_entity)) - .and_then(|e| get_inclusion_modifier.modifiers.get(*e).ok()); - if let Some(inclusion_modifier) = inclusion_modifier { - // Either explicitly included or hidden - if **inclusion_modifier == Inclusion::Hidden { - if ui - .add(ImageButton::new(icons.hide.egui())) - .on_hover_text("Task is hidden in this scenario") - .clicked() - { - commands.entity(task_entity).insert(Inclusion::Included); - } - } else { - if ui - .add(ImageButton::new(icons.show.egui())) - .on_hover_text("Task is included in this scenario") - .clicked() - { - if get_inclusion_modifier - .scenarios - .get(scenario) - .is_ok_and(|(_, a)| a.0.is_some()) - { - // If parent scenario exists, clicking this button toggles to ResetInclusion - commands.trigger(UpdateModifier::::reset( - scenario, - task_entity, - )); - } else { - // Otherwise, toggle to Hidden - commands.entity(task_entity).insert(Inclusion::Hidden); - } - } - } - } else { - // Modifier is inherited - if ui - .add(ImageButton::new(icons.link.egui())) - .on_hover_text("Task inclusion is inherited in this scenario") - .clicked() + } else { + if ui + .add(ImageButton::new(icons.show.egui())) + .on_hover_text("Task is included in this scenario") + .clicked() + { + if get_inclusion_modifier + .scenarios + .get(scenario) + .is_ok_and(|(_, a)| a.0.is_some()) { + // If parent scenario exists, clicking this button toggles to ResetInclusion + commands + .trigger(UpdateModifier::::reset(scenario, task_entity)); + } else { + // Otherwise, toggle to Hidden commands.entity(task_entity).insert(Inclusion::Hidden); } } - if present { - // Do not allow edit if not in current scenario - if ui - .add(ImageButton::new(icons.edit.egui())) - .on_hover_text("Edit task parameters") - .clicked() - { - edit = true; - } - } - }); - }); - if !present { - return; + } + } else { + // Modifier is inherited + if ui + .add(ImageButton::new(icons.link.egui())) + .on_hover_text("Task inclusion is inherited in this scenario") + .clicked() + { + commands.entity(task_entity).insert(Inclusion::Hidden); + } } - ui.separator(); - let Some(task_params) = get_params_modifier - .get(scenario, task_entity) - .map(|m| (**m).clone()) - else { - return; - }; - let task_request = task.request(); - Grid::new("show_task_".to_owned() + &task_entity.index().to_string()) - .num_columns(2) - .show(ui, |ui| { - match task { - Task::Dispatch(_) => { - ui.label("Dispatch:"); - ui.label( - task_request - .fleet_name() - .unwrap_or("Unassigned".to_string()), - ); - ui.end_row(); - } - Task::Direct(_) => { - ui.label("Direct:"); - ui.label(task.fleet().to_owned() + "/" + &task.robot()); - ui.end_row(); - } + if !in_edit_mode { + if present { + // Do not allow edit if not in current scenario + if ui + .add(ImageButton::new(icons.edit.egui())) + .on_hover_text("Edit task parameters") + .clicked() + { + edit_mode.write(EditModeEvent { + scenario: scenario, + mode: EditMode::Edit(Some(task_entity)), + }); } + } + } else { + // Exit edit mode + if ui + .add(ImageButton::new(icons.confirm.egui())) + .on_hover_text("Exit edit mode") + .clicked() + { + edit_mode.write(EditModeEvent { + scenario: scenario, + mode: EditMode::Edit(None), + }); + } + } + }); + }); + if !present { + return; + } + ui.separator(); - ui.label("Kind:"); - ui.label(task_request.category()); - ui.end_row(); - - ui.label("Description:"); - ui.label( - task_request - .description_display() - .unwrap_or("None".to_string()), - ); - ui.end_row(); - - ui.label("Requester:"); - ui.label(task_request.requester().unwrap_or("None".to_string())); - ui.end_row(); - - ui.label("Fleet name:"); - ui.label(task_request.fleet_name().unwrap_or("None".to_string())); - ui.end_row(); - }); - - CollapsingHeader::new("More details") - .id_salt("task_details_".to_owned() + &task_entity.index().to_string()) - .default_open(false) - .show(ui, |ui| { - Grid::new("task_details_".to_owned() + &task_entity.index().to_string()) - .num_columns(2) - .show(ui, |ui| { - // TODO(@xiyuoh) Add status/queued information - ui.label("Start time:"); - ui.label( - task_params - .start_time() - .map(|rt| format!("{:?}", rt)) - .unwrap_or("None".to_string()), - ); - ui.end_row(); - - ui.label("Request time:"); - ui.label( - task_params - .request_time() - .map(|rt| format!("{:?}", rt)) - .unwrap_or("None".to_string()), - ); - ui.end_row(); + let Some(task_params) = get_params_modifier + .get(scenario, task_entity) + .map(|m| (**m).clone()) + else { + return; + }; - ui.label("Priority:"); - ui.label( - task_params - .priority() - .map(|st| st.to_string()) - .unwrap_or("None".to_string()), - ); - ui.end_row(); - - ui.label("Labels:"); - ui.label(format!("{:?}", task_params.labels())); - ui.end_row(); - - // Reset task parameters to parent scenario params (if any) - if let Ok((scenario_modifiers, parent_scenario)) = - get_params_modifier.scenarios.get(scenario) - { - if scenario_modifiers - .get(&task_entity) - .is_some_and(|e| get_params_modifier.modifiers.get(*e).is_ok()) - && parent_scenario.0.is_some() - { - // Only display reset button if this task has a TaskParams modifier - // and this is not a root scenario - if ui - .button("Reset Task Params") - .on_hover_text( - "Reset task parameters to parent scenario params", - ) - .clicked() - { - commands.trigger(UpdateModifier::::reset( - scenario, - task_entity, - )); - } - ui.end_row(); - } - } - }); - }); - }); - edit + show_editable_task( + ui, + commands, + task_entity, + task, + &task_params, + scenario, + in_edit_mode, + get_params_modifier, + robots, + task_kinds, + hover, + ); } -fn edit_task( +fn show_editable_task( ui: &mut Ui, commands: &mut Commands, task_entity: Entity, - task: &Task, + task: &Task, task_params: &TaskParams, + scenario: Entity, + in_edit_mode: bool, + get_params_modifier: &GetModifier>, + robots: &Query<(Entity, &NameInSite, &Robot), Without>, task_kinds: &ResMut, - robots: &Query<(Entity, &NameInSite), (With, Without)>, + hover: &mut EventWriter, ) { - Grid::new("edit_task_".to_owned() + &task_entity.index().to_string()) + let mut new_task = task.clone(); + let task_request = new_task.request(); + Grid::new("show_editable_task_".to_owned() + &task_entity.index().to_string()) .num_columns(2) .show(ui, |ui| { - let mut new_task = task.clone(); - - // Select Request Type - let mut is_robot_task_request = new_task.is_direct(); + // Request Type ui.label("Request Type:"); - ui.horizontal(|ui| { - if ui - .selectable_label(!is_robot_task_request, "Dispatch") - .on_hover_text("Create a Dispatch Task for any robot in the site") - .clicked() - { - is_robot_task_request = false; - }; - if ui - .selectable_label(is_robot_task_request, "Direct") - .on_hover_text("Create a Direct Task for a specific robot in a fleet") - .clicked() - { - is_robot_task_request = true; - } - }); - ui.end_row(); - - // Update Request Type and show RobotTaskRequest widget - let task_request = new_task.request(); - if is_robot_task_request { - if !new_task.is_direct() { - let robot = task.robot(); - let fleet = task.fleet(); - new_task = - Task::Direct(RobotTaskRequest::new(robot, fleet, task_request.clone())); - } - if let Task::Direct(ref mut robot_task_request) = new_task { - ui.label("Fleet:"); - ui.add(TextEdit::singleline(robot_task_request.fleet_mut())); - ui.end_row(); - - ui.label("Robot:"); - let selected_robot = if robot_task_request.robot().is_empty() { - "Select Robot".to_string() - } else { - robot_task_request.robot() - }; - ComboBox::from_id_salt("select_robot_for_task") - .selected_text(selected_robot) - .show_ui(ui, |ui| { - for (_, robot) in robots.iter() { - ui.selectable_value( - robot_task_request.robot_mut(), - robot.0.clone(), - robot.0.clone(), - ); - } + if !in_edit_mode { + match task { + Task::Dispatch(_) => { + ui.horizontal(|ui| { + let _ = ui.selectable_label(true, "Dispatch"); + ui.label( + task_request + .fleet_name() + .unwrap_or("Unassigned".to_string()), + ); }); - ui.end_row(); - } else { - warn!("Unable to select Direct task!"); + } + Task::Direct(_) => { + let robot_name = task + .robot() + .0 + .and_then(|e| robots.get(e).ok()) + .map(|(_, name, _)| name.0.clone()) + .unwrap_or("".to_string()); + ui.horizontal(|ui| { + let _ = ui.selectable_label(true, "Direct"); + ui.label(task.fleet().to_owned() + "/" + &robot_name); + }); + } } } else { - if !new_task.is_dispatch() { - new_task = Task::Dispatch(DispatchTaskRequest::new(task_request.clone())); - } + edit_request_type_widget( + ui, + &mut new_task, + &task_request, + robots, + task.robot(), + task.fleet(), + hover, + ); } - // Show TaskRequest editing widget - let current_category = new_task.request().category(); - let selected_task_kind = if task_kinds.0.contains_key(¤t_category) { - current_category.clone() - } else { - "Select Kind".to_string() - }; - ui.label("Task Kind:"); - ComboBox::from_id_salt("select_task_kind") - .selected_text(selected_task_kind) - .show_ui(ui, |ui| { - for (kind, _) in task_kinds.0.iter() { - ui.selectable_value( - new_task.request_mut().category_mut(), - kind.clone(), - kind.clone(), - ); - } - }); ui.end_row(); - // Insert selected TaskKind component - let new_category = new_task.request().category(); - if new_category != current_category { - if let Some(remove_fn) = task_kinds.0.get(¤t_category).map(|(_, rm_fn)| rm_fn) - { - remove_fn(commands.entity(task_entity)); - } - if let Some(insert_fn) = task_kinds.0.get(&new_category).map(|(is_fn, _)| is_fn) { - insert_fn(commands.entity(task_entity)); - } - } - - let new_task_request = new_task.request_mut(); - // Requester - ui.label("Requester:") - .on_hover_text("(Optional) An identifier for the entity that requested this task"); - let requester = new_task_request - .requester_mut() - .get_or_insert(String::new()); - ui.text_edit_singleline(requester); - if requester.is_empty() { - *new_task_request.requester_mut() = None; + // Task Kind + ui.label("Task Kind:"); + if !in_edit_mode { + ui.label(task_request.category()); + } else { + edit_task_kind_widget(ui, commands, &mut new_task, task_entity, task_kinds); } ui.end_row(); - // Fleet name - ui.label("Fleet name:").on_hover_text( - "(Optional) The name of the fleet that should perform this task. \ - If specified, other fleets will not bid for this task.", - ); - // TODO(@xiyuoh) when available, insert combobox of registered fleets - let fleet_name = new_task_request - .fleet_name_mut() - .get_or_insert(String::new()); - ui.text_edit_singleline(fleet_name); - if fleet_name.is_empty() { - *new_task_request.fleet_name_mut() = None; + // Task Description + // Only displayed when not editing; the TaskKind widget will appear when editing + if !in_edit_mode { + ui.label("Description:"); + ui.label( + task_request + .description_display() + .unwrap_or("None".to_string()), + ); + ui.end_row(); } - ui.end_row(); - if new_task != *task { - commands.trigger(Change::new(new_task, task_entity)); + // Requester + ui.label("Requester:") + .on_hover_text("(Optional) An identifier for the entity that requested this task"); + if !in_edit_mode { + ui.label(task_request.requester().unwrap_or("None".to_string())); } else { + edit_requester_widget(ui, &mut new_task); } + ui.end_row(); }); // More - CollapsingHeader::new("More") + let mut new_task_params = task_params.clone(); + CollapsingHeader::new("More details") + .id_salt("task_details_".to_owned() + &task_entity.index().to_string()) .default_open(false) .show(ui, |ui| { - Grid::new("edit_task_details") - .num_columns(2) - .show(ui, |ui| { - let mut new_task_params = task_params.clone(); - - // Start time - ui.label("Start Time:") - .on_hover_text("(Optional) The earliest time that this task may start"); - let start_time = new_task_params.start_time(); - let mut has_start_time = start_time.is_some(); - ui.horizontal(|ui| { - ui.checkbox(&mut has_start_time, ""); - if has_start_time { - let new_start_time = new_task_params.start_time_mut().get_or_insert(0); - ui.add( - DragValue::new(new_start_time) - .range(0_i32..=std::i32::MAX) - .speed(1), - ); - } else if start_time.is_some() { - *new_task_params.start_time_mut() = None; - } - }); - ui.end_row(); - - // Request time - ui.label("Request Time:") - .on_hover_text("(Optional) The time that this request was initiated"); - let request_time = new_task_params.request_time(); - let mut has_request_time = request_time.is_some(); - ui.horizontal(|ui| { - ui.checkbox(&mut has_request_time, ""); - if has_request_time { - let new_request_time = - new_task_params.request_time_mut().get_or_insert(0); - ui.add( - DragValue::new(new_request_time) - .range(0_i32..=std::i32::MAX) - .speed(1), - ); - } else if request_time.is_some() { - *new_task_params.request_time_mut() = None; - } - }); - ui.end_row(); + // Fleet name + if task.is_dispatch() { + ui.horizontal(|ui| { + ui.label("Fleet:").on_hover_text( + "(Optional) The name of the fleet for this robot. \ + If specified, other fleets will not bid for this task.", + ); + if !in_edit_mode { + ui.label(task_request.fleet_name().unwrap_or("None".to_string())); + } else { + edit_fleet_widget(ui, &mut new_task, robots); + } + }); + } + + // Start time + // TODO(@xiyuoh) Add status/queued information + ui.horizontal(|ui| { + ui.label("Start time:") + .on_hover_text("(Optional) The earliest time that this task may start"); + if !in_edit_mode { + ui.label( + task_params + .start_time() + .map(|st| format!("{:?}", st)) + .unwrap_or("None".to_string()), + ); + } else { + edit_start_time_widget(ui, &mut new_task_params); + } + }); + + // Request time + ui.horizontal(|ui| { + ui.label("Request time:") + .on_hover_text("(Optional) The time that this request was initiated"); + if !in_edit_mode { + ui.label( + task_params + .request_time() + .map(|rt| format!("{:?}", rt)) + .unwrap_or("None".to_string()), + ); + } else { + edit_request_time_widget(ui, &mut new_task_params); + } + }); - // Priority - ui.label("Priority:").on_hover_text( - "(Optional) The priority of this task. \ + // Priority + ui.horizontal(|ui| { + ui.label("Priority:").on_hover_text( + "(Optional) The priority of this task. \ This must match a priority schema supported by a fleet.", + ); + if !in_edit_mode { + ui.label( + task_params + .priority() + .map(|p| p.to_string()) + .unwrap_or("None".to_string()), ); - let priority = new_task_params.priority(); - let mut has_priority = priority.is_some(); - ui.checkbox(&mut has_priority, ""); - ui.end_row(); - if has_priority { - if priority.is_none() { - *new_task_params.priority_mut() = Some(Value::Null); - } - // TODO(@xiyuoh) Expand on this to create fleet-specific priority widgets - } else if priority.is_some() { - *new_task_params.priority_mut() = None; - } + } else { + edit_priority_widget(ui, &mut new_task_params); + } + }); - // Labels - ui.label("Labels:").on_hover_text( - "Labels to describe the purpose of the task dispatch request, \ + // Labels + ui.horizontal(|ui| { + ui.label("Labels:").on_hover_text( + "Labels to describe the purpose of the task dispatch request, \ items can be a single value like `dashboard` or a key-value pair \ like `app=dashboard`, in the case of a single value, it will be \ interpreted as a key-value pair with an empty string value.", - ); - let mut remove_labels = Vec::new(); - let mut id: usize = 0; - for label in new_task_params.labels_mut() { - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if ui.button("❌").on_hover_text("Remove label").clicked() { - remove_labels.push(id.clone()); - } - ui.text_edit_singleline(label); - }); - id += 1; - ui.end_row(); - ui.label(""); + ); + if !in_edit_mode { + ui.label(format!("{:?}", task_params.labels())); + } else { + edit_labels_widget(ui, &mut new_task_params); + } + }); + + // Reset task parameters to parent scenario params (if any) + if let Ok((scenario_modifiers, parent_scenario)) = + get_params_modifier.scenarios.get(scenario) + { + if scenario_modifiers + .get(&task_entity) + .is_some_and(|e| get_params_modifier.modifiers.get(*e).is_ok()) + && parent_scenario.0.is_some() + { + // Only display reset button if this task has a TaskParams modifier + // and this is not a root scenario + if ui + .button("Reset Task Params") + .on_hover_text("Reset task parameters to parent scenario params") + .clicked() + { + commands + .trigger(UpdateModifier::::reset(scenario, task_entity)); } - ui.with_layout(Layout::right_to_left(Align::Max), |ui| { - if ui - .button("Add label") - .on_hover_text("Insert new label") - .clicked() - { - new_task_params.labels_mut().push(String::new()); - } - }); - ui.end_row(); - for i in remove_labels.drain(..).rev() { - new_task_params.labels_mut().remove(i); + } + } + }); + + // Trigger appropriate events if changes have been made in edit mode + if in_edit_mode { + if new_task != *task { + commands.trigger(Change::new(new_task, task_entity)); + } + + if new_task_params != *task_params { + commands.entity(task_entity).insert(new_task_params); + } + } +} + +fn show_create_task_dialog( + world: &mut World, + task_state: &mut SystemState<( + Res, + Res, + Query<(&Task, &TaskParams), With>, + )>, + egui_context_state: &mut SystemState, + edit_state: &mut SystemState<( + Commands, + GetModifier>, + Query<(Entity, &NameInSite, &Robot), Without>, + ResMut, + EventWriter, + )>, + widget_state: &mut SystemState<(Query<&Children>, Res)>, + dialog_state: &mut SystemState<(ResMut, EventWriter)>, +) { + let (create_task_dialog, _) = dialog_state.get_mut(world); + if !create_task_dialog.visible { + return; + } + + let (current_scenario, edit_task, pending_tasks) = task_state.get_mut(world); + let Some(current_scenario_entity) = current_scenario.0 else { + return; + }; + let Some(task_entity) = edit_task.0 else { + return; + }; + let Ok((pending_task, pending_task_params)) = pending_tasks.get(task_entity) else { + return; + }; + let (pending_task, pending_task_params) = (pending_task.clone(), pending_task_params.clone()); + + let mut egui_context = egui_context_state.get_mut(world); + let mut ctx = egui_context.ctx_mut().clone(); + Window::new("Creating New Task") + .collapsible(false) + .resizable(false) + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) + .show(&mut ctx, |ui| { + let (mut commands, get_params_modifier, robots, task_kinds, mut hover) = + edit_state.get_mut(world); + show_editable_task( + ui, + &mut commands, + task_entity, + &pending_task, + &pending_task_params, + current_scenario_entity, + true, + &get_params_modifier, + &robots, + &task_kinds, + &mut hover, + ); + let task_request_category = pending_task.request().category(); + let task_kind_is_valid = + if let Some((_, _, valid_fn)) = task_kinds.0.get(&task_request_category) { + Some(valid_fn.clone()) + } else { + None + }; + edit_state.apply(world); + ui.separator(); + + let (children, task_widget) = widget_state.get_mut(world); + let children: Result, _> = children + .get(task_widget.id) + .map(|children| children.iter().collect()); + let Ok(children) = children else { + return; + }; + + let widget_entity = task_widget.id; + for child in children { + let tile = Tile { + id: widget_entity, + panel: PanelSide::Top, // Any panel will do + }; + let _ = world.try_show_in(child, tile, ui); + } + + let mut reset_edit: bool = false; + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.button("Cancel").clicked() { + reset_edit = true; + } + let task_is_valid = if let Some(task_kind_is_valid) = task_kind_is_valid { + pending_task.is_valid() && task_kind_is_valid(task_entity, world) + } else { + // If task kind valid fn cannot be retrieved, task is invalid + false + }; + ui.add_enabled_ui(task_is_valid, |ui| { + if ui + .button("Add Task") + .on_hover_text("Add this task to the current scenario") + .clicked() + { + // Update TaskParams modifier after removing Pending to + // for changes to take effect + world + .entity_mut(task_entity) + .remove::() + .insert(pending_task_params); + reset_edit = true; } + }); + }); + + if reset_edit { + let (mut create_task_dialog, mut edit_mode) = dialog_state.get_mut(world); + edit_mode.write(EditModeEvent { + scenario: current_scenario_entity, + mode: EditMode::Edit(None), + }); + create_task_dialog.visible = false; + } + }); +} + +fn edit_request_type_widget( + ui: &mut Ui, + task: &mut Task, + task_request: &TaskRequest, + robots: &Query<(Entity, &NameInSite, &Robot), Without>, + robot: Affiliation, + fleet: String, + hover: &mut EventWriter, +) { + let mut is_robot_task_request = task.is_direct(); + ui.horizontal(|ui| { + if ui + .selectable_label(!is_robot_task_request, "Dispatch") + .on_hover_text("Create a Dispatch Task for any robot in the site") + .clicked() + { + is_robot_task_request = false; + }; + if ui + .selectable_label(is_robot_task_request, "Direct") + .on_hover_text("Create a Direct Task for a specific robot in a fleet") + .clicked() + { + is_robot_task_request = true; + } + }); + // Update Request Type and show RobotTaskRequest widget + if is_robot_task_request { + if !task.is_direct() { + *task = Task::Direct(RobotTaskRequest::new(robot, fleet, task_request.clone())); + } + if let Task::Direct(ref mut robot_task_request) = task { + ui.end_row(); - if new_task_params != *task_params { - commands.entity(task_entity).insert(new_task_params); + ui.label("Robot:"); + let selected_robot = if let Some((_, robot_name, _)) = robot_task_request + .robot() + .0 + .and_then(|e| robots.get(e).ok()) + { + robot_name.0.clone() + } else { + "Select Robot".to_string() + }; + ComboBox::from_id_salt("select_robot_for_task") + .selected_text(selected_robot) + .show_ui(ui, |ui| { + // Sort robots alphabetically + let mut sorted_robots = robots.iter().fold( + Vec::<(Entity, String, Robot)>::new(), + |mut l, (e, name, r)| { + l.push((e, name.0.clone(), r.clone())); + l + }, + ); + sorted_robots.sort_by(|a, b| a.1.cmp(&b.1)); + for (robot_entity, robot_name, robot) in sorted_robots.iter() { + let requested_robot = robot_task_request.robot_mut(); + let resp = ui.add(SelectableLabel::new( + requested_robot.0.is_some_and(|e| e == *robot_entity), + robot_name.clone(), + )); + if resp.clicked() { + *requested_robot = Affiliation(Some(*robot_entity)); + } else if resp.hovered() { + hover.write(Hover(Some(*robot_entity))); + } + // Update fleet according to selected robot + if robot_task_request + .robot() + .0 + .is_some_and(|e| e == *robot_entity) + { + *robot_task_request.fleet_mut() = robot.fleet.clone(); + } } }); + ui.end_row(); + + ui.label("Fleet:"); + ui.label(robot_task_request.fleet()); + } else { + warn!("Unable to select Direct task!"); + } + } else { + if !task.is_dispatch() { + *task = Task::Dispatch(DispatchTaskRequest::new(task_request.clone())); + } + } +} + +fn edit_task_kind_widget( + ui: &mut Ui, + commands: &mut Commands, + task: &mut Task, + task_entity: Entity, + task_kinds: &ResMut, +) { + let current_category = task.request().category(); + let selected_task_kind = if task_kinds.0.contains_key(¤t_category) { + current_category.clone() + } else { + "Select Kind".to_string() + }; + ComboBox::from_id_salt("select_task_kind") + .selected_text(selected_task_kind) + .show_ui(ui, |ui| { + for (kind, _) in task_kinds.0.iter() { + ui.selectable_value( + task.request_mut().category_mut(), + kind.clone(), + kind.clone(), + ); + } + }); + // Insert selected TaskKind component + let new_category = task.request().category(); + if new_category != current_category { + if let Some(remove_fn) = task_kinds + .0 + .get(¤t_category) + .map(|(_, rm_fn, _)| rm_fn) + { + remove_fn(commands.entity(task_entity)); + } + if let Some(insert_fn) = task_kinds.0.get(&new_category).map(|(is_fn, _, _)| is_fn) { + insert_fn(commands.entity(task_entity)); + } + } +} + +fn edit_requester_widget(ui: &mut Ui, task: &mut Task) { + let new_task_request = task.request_mut(); + let requester = new_task_request + .requester_mut() + .get_or_insert(String::new()); + TextEdit::singleline(requester) + .desired_width(ui.available_width()) + .show(ui); + if requester.is_empty() { + *new_task_request.requester_mut() = None; + } +} + +fn edit_fleet_widget( + ui: &mut Ui, + task: &mut Task, + robots: &Query<(Entity, &NameInSite, &Robot), Without>, +) { + // TODO(@xiyuoh) when available, insert combobox of registered fleets + let new_task_request = task.request_mut(); + let fleet_name = new_task_request + .fleet_name_mut() + .get_or_insert(String::new()); + // Sort fleets alphabetically; only list fleets with robots + let mut sorted_fleets = robots + .iter() + .fold(Vec::::new(), |mut l, (_, _, robot)| { + if !l.contains(&robot.fleet) { + l.push(robot.fleet.clone()); + } + l }); + sorted_fleets.sort(); + ComboBox::from_id_salt("select_fleet") + .selected_text(fleet_name.clone()) + .show_ui(ui, |ui| { + for f in sorted_fleets.iter() { + ui.selectable_value(fleet_name, f.clone(), f.clone()); + } + }); + if fleet_name.is_empty() { + *new_task_request.fleet_name_mut() = None; + } +} + +fn edit_start_time_widget(ui: &mut Ui, task_params: &mut TaskParams) { + let start_time = task_params.start_time(); + let mut has_start_time = start_time.is_some(); + ui.horizontal(|ui| { + ui.checkbox(&mut has_start_time, ""); + if has_start_time { + let new_start_time = task_params.start_time_mut().get_or_insert(0); + ui.add( + DragValue::new(new_start_time) + .range(0_i32..=std::i32::MAX) + .speed(1), + ); + } else if start_time.is_some() { + *task_params.start_time_mut() = None; + } + }); +} + +fn edit_request_time_widget(ui: &mut Ui, task_params: &mut TaskParams) { + let request_time = task_params.request_time(); + let mut has_request_time = request_time.is_some(); + ui.horizontal(|ui| { + ui.checkbox(&mut has_request_time, ""); + if has_request_time { + let new_request_time = task_params.request_time_mut().get_or_insert(0); + ui.add( + DragValue::new(new_request_time) + .range(0_i32..=std::i32::MAX) + .speed(1), + ); + } else if request_time.is_some() { + *task_params.request_time_mut() = None; + } + }); +} + +fn edit_priority_widget(ui: &mut Ui, task_params: &mut TaskParams) { + let priority = task_params.priority(); + let mut has_priority = priority.is_some(); + ui.checkbox(&mut has_priority, ""); + if has_priority { + if priority.is_none() { + *task_params.priority_mut() = Some(Value::Null); + } + // TODO(@xiyuoh) Expand on this to create fleet-specific priority widgets + } else if priority.is_some() { + *task_params.priority_mut() = None; + } +} + +fn edit_labels_widget(ui: &mut Ui, task_params: &mut TaskParams) { + let mut remove_labels = Vec::new(); + let mut id: usize = 0; + ui.vertical(|ui| { + for label in task_params.labels_mut() { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.button("❌").on_hover_text("Remove label").clicked() { + remove_labels.push(id.clone()); + } + TextEdit::singleline(label) + .desired_width(ui.available_width()) + .show(ui); + }); + id += 1; + } + ui.with_layout(Layout::right_to_left(Align::Max), |ui| { + if ui + .button("Add label") + .on_hover_text("Insert new label") + .clicked() + { + task_params.labels_mut().push(String::new()); + } + }); + }); + for i in remove_labels.drain(..).rev() { + task_params.labels_mut().remove(i); + } } /// Updates the current EditTask entity based on the triggered edit mode event pub fn handle_task_edit( mut commands: Commands, + mut create_task_dialog: ResMut, mut delete: EventWriter, mut edit_mode: EventReader, mut edit_task: ResMut, - pending_tasks: Query<&mut Task, With>, + pending_tasks: Query<&mut Task, With>, current_workspace: Res, ) { - // TODO(@xiyuoh) fix bug where the egui panel glitches when the EditTask resource is being accessed if let Some(edit) = edit_mode.read().last() { match edit.mode { EditMode::New(task_entity) => { @@ -808,6 +1086,7 @@ pub fn handle_task_edit( commands.entity(task_entity).insert(ChildOf(site_entity)); } edit_task.0 = Some(task_entity); + create_task_dialog.visible = true; } EditMode::Edit(task_entity) => { if let Some(pending_task) = edit_task.0.filter(|e| pending_tasks.get(*e).is_ok()) { diff --git a/crates/rmf_site_editor/src/widgets/tasks/wait_for.rs b/crates/rmf_site_editor/src/widgets/tasks/wait_for.rs index ad623b8a..7412e64a 100644 --- a/crates/rmf_site_editor/src/widgets/tasks/wait_for.rs +++ b/crates/rmf_site_editor/src/widgets/tasks/wait_for.rs @@ -44,6 +44,7 @@ impl Plugin for WaitForPlugin { |mut e_cmd| { e_cmd.remove::(); }, + |e, world| world.entity(e).get::().is_some(), ), ); let widget = Widget::::new::(&mut app.world_mut()); @@ -56,7 +57,7 @@ impl Plugin for WaitForPlugin { #[derive(SystemParam)] pub struct ViewWaitFor<'w, 's> { edit_task: Res<'w, EditTask>, - tasks: Query<'w, 's, (&'static mut WaitFor, &'static mut Task)>, + tasks: Query<'w, 's, (&'static mut WaitFor, &'static mut Task)>, } impl<'w, 's> WidgetSystem for ViewWaitFor<'w, 's> { diff --git a/crates/rmf_site_format/Cargo.toml b/crates/rmf_site_format/Cargo.toml index c843264f..b7b8a8cc 100644 --- a/crates/rmf_site_format/Cargo.toml +++ b/crates/rmf_site_format/Cargo.toml @@ -27,6 +27,7 @@ urdf-rs = { workspace = true, optional = true } # Used for lazy initialization of static variable when they are non const once_cell = {workspace = true} pathdiff = {workspace = true} +chrono = { workspace = true } [dev-dependencies] float_eq = {workspace = true} diff --git a/crates/rmf_site_format/src/legacy/building_map.rs b/crates/rmf_site_format/src/legacy/building_map.rs index d01a8975..9ea0caa4 100644 --- a/crates/rmf_site_format/src/legacy/building_map.rs +++ b/crates/rmf_site_format/src/legacy/building_map.rs @@ -210,7 +210,7 @@ impl BuildingMap { let mut model_instances: BTreeMap>> = BTreeMap::new(); let mut model_description_name_map = HashMap::::new(); let mut scenarios: BTreeMap> = BTreeMap::new(); - let tasks: BTreeMap = BTreeMap::new(); // Tasks not supported in legacy + let tasks: BTreeMap> = BTreeMap::new(); // Tasks not supported in legacy let default_scenario_id = site_id.next().unwrap(); scenarios.insert(default_scenario_id, Scenario::default()); diff --git a/crates/rmf_site_format/src/robot.rs b/crates/rmf_site_format/src/robot.rs index 9c1ef2a8..a396273b 100644 --- a/crates/rmf_site_format/src/robot.rs +++ b/crates/rmf_site_format/src/robot.rs @@ -26,12 +26,16 @@ use std::collections::BTreeMap; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[cfg_attr(feature = "bevy", derive(Component), require(OnLevel))] pub struct Robot { + // TODO(@xiyuoh) Fleet name is a string for now, we probably want some kind of + // fleet registration at some point + pub fleet: String, pub properties: BTreeMap, } impl Default for Robot { fn default() -> Self { Self { + fleet: "".to_string(), properties: BTreeMap::new(), } } diff --git a/crates/rmf_site_format/src/site.rs b/crates/rmf_site_format/src/site.rs index 8fb4a7d6..d68e0b3c 100644 --- a/crates/rmf_site_format/src/site.rs +++ b/crates/rmf_site_format/src/site.rs @@ -152,7 +152,7 @@ pub struct Site { pub model_instances: BTreeMap>>, /// Tasks available in this site #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub tasks: BTreeMap, + pub tasks: BTreeMap>, /// Hook for downstream extensions to put their own serialized data into /// the site file. #[serde(default, skip_serializing_if = "is_default")] diff --git a/crates/rmf_site_format/src/task.rs b/crates/rmf_site_format/src/task.rs index 8838dfe6..beea84fc 100644 --- a/crates/rmf_site_format/src/task.rs +++ b/crates/rmf_site_format/src/task.rs @@ -16,8 +16,14 @@ */ use crate::*; +use chrono::DateTime; use serde::{Deserialize, Serialize}; -use std::fmt; +use std::{ + collections::HashMap, + fmt, + time::{SystemTime, UNIX_EPOCH}, +}; +use uuid::Uuid; #[cfg(feature = "bevy")] use { bevy::prelude::{Component, Reflect}, @@ -27,30 +33,26 @@ use { #[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)] #[cfg_attr(feature = "bevy", derive(Component))] pub struct TaskParams { - #[serde(default, skip_serializing_if = "is_default")] - pub unix_millis_earliest_start_time: Option, - #[serde(default, skip_serializing_if = "is_default")] - pub unix_millis_request_time: Option, - #[serde(default, skip_serializing_if = "is_default")] + pub unix_millis_earliest_start_time: Option, + pub unix_millis_request_time: Option, pub priority: Option, - #[serde(default, skip_serializing_if = "is_default")] pub labels: Vec, } impl TaskParams { - pub fn start_time(&self) -> Option { + pub fn start_time(&self) -> Option { self.unix_millis_earliest_start_time.clone() } - pub fn start_time_mut(&mut self) -> &mut Option { + pub fn start_time_mut(&mut self) -> &mut Option { &mut self.unix_millis_earliest_start_time } - pub fn request_time(&self) -> Option { + pub fn request_time(&self) -> Option { self.unix_millis_request_time.clone() } - pub fn request_time_mut(&mut self) -> &mut Option { + pub fn request_time_mut(&mut self) -> &mut Option { &mut self.unix_millis_request_time } @@ -73,6 +75,7 @@ impl TaskParams { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct TaskRequest { + pub id: String, pub category: String, #[serde(default, skip_serializing_if = "is_default")] pub description: serde_json::Value, @@ -82,16 +85,29 @@ pub struct TaskRequest { pub requester: Option, #[serde(default, skip_serializing_if = "is_default")] pub fleet_name: Option, + #[serde(default, skip_serializing_if = "is_default")] + pub created_time: Option, } impl Default for TaskRequest { fn default() -> Self { + let created_time = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .ok(); + let id = created_time + .map(|t| std::time::Duration::from_millis(t as u64)) + .and_then(|d| DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos())) + .map(|dt| format!("task-{}", dt.format("%Y%m%d-%H%M%S").to_string())) + .unwrap_or(format!("task-{}", Uuid::new_v4().to_string())); TaskRequest { + id, category: "".to_string(), description: serde_json::Value::Null, description_display: None, requester: None, fleet_name: None, + created_time, } } } @@ -104,6 +120,14 @@ impl TaskRequest { true } + pub fn id(&self) -> String { + self.id.clone() + } + + pub fn id_mut(&mut self) -> &mut String { + &mut self.id + } + pub fn category(&self) -> String { self.category.clone() } @@ -143,6 +167,14 @@ impl TaskRequest { pub fn fleet_name_mut(&mut self) -> &mut Option { &mut self.fleet_name } + + pub fn created_time(&self) -> Option { + self.created_time.clone() + } + + pub fn created_time_mut(&mut self) -> &mut Option { + &mut self.created_time + } } pub trait TaskRequestType { @@ -179,18 +211,18 @@ impl DispatchTaskRequest { } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] -pub struct RobotTaskRequest { - pub robot: String, +pub struct RobotTaskRequest { + pub robot: Affiliation, pub fleet: String, pub request: TaskRequest, } -impl TaskRequestType for RobotTaskRequest { +impl TaskRequestType for RobotTaskRequest { fn is_valid(&self) -> bool { if self.fleet.is_empty() { return false; } - if self.robot.is_empty() { + if self.robot.0.is_none() { return false; } self.request.is_valid() @@ -205,8 +237,8 @@ impl TaskRequestType for RobotTaskRequest { } } -impl RobotTaskRequest { - pub fn new(robot: String, fleet: String, request: TaskRequest) -> Self { +impl RobotTaskRequest { + pub fn new(robot: Affiliation, fleet: String, request: TaskRequest) -> Self { Self { robot, fleet, @@ -214,11 +246,11 @@ impl RobotTaskRequest { } } - pub fn robot(&self) -> String { + pub fn robot(&self) -> Affiliation { self.robot.clone() } - pub fn robot_mut(&mut self) -> &mut String { + pub fn robot_mut(&mut self) -> &mut Affiliation { &mut self.robot } @@ -232,80 +264,93 @@ impl RobotTaskRequest { } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] -#[cfg_attr(feature = "bevy", derive(Component))] -pub enum Task { +#[cfg_attr(feature = "bevy", derive(Component), require(TaskParams))] +pub enum Task { Dispatch(DispatchTaskRequest), - Direct(RobotTaskRequest), + Direct(RobotTaskRequest), } -impl Default for Task { +impl Default for Task { fn default() -> Self { - Task::Dispatch(DispatchTaskRequest { + Task::::Dispatch(DispatchTaskRequest { request: TaskRequest::default(), }) } } -impl Task { +impl Task { + pub fn convert(&self, id_map: &HashMap) -> Result, T> { + match self { + Task::::Dispatch(request) => Ok(Task::::Dispatch(request.clone())), + Task::::Direct(request) => Ok(Task::::Direct(RobotTaskRequest { + robot: request.robot.convert(id_map)?, + fleet: request.fleet.clone(), + request: request.request.clone(), + })), + } + } +} + +impl Task { pub fn is_valid(&self) -> bool { match self { - Task::Dispatch(dispatch_task_request) => dispatch_task_request.is_valid(), - Task::Direct(robot_task_request) => robot_task_request.is_valid(), + Task::::Dispatch(dispatch_task_request) => dispatch_task_request.is_valid(), + Task::::Direct(robot_task_request) => robot_task_request.is_valid(), } } pub fn is_dispatch(&self) -> bool { match self { - Task::Dispatch(_) => true, + Task::::Dispatch(_) => true, _ => false, } } pub fn is_direct(&self) -> bool { match self { - Task::Direct(_) => true, + Task::::Direct(_) => true, _ => false, } } pub fn request(&self) -> TaskRequest { match self { - Task::Dispatch(dispatch_task_request) => dispatch_task_request.request(), - Task::Direct(robot_task_request) => robot_task_request.request(), + Task::::Dispatch(dispatch_task_request) => dispatch_task_request.request(), + Task::::Direct(robot_task_request) => robot_task_request.request(), } } pub fn request_mut(&mut self) -> &mut TaskRequest { match self { - Task::Dispatch(dispatch_task_request) => dispatch_task_request.request_mut(), - Task::Direct(robot_task_request) => robot_task_request.request_mut(), + Task::::Dispatch(dispatch_task_request) => dispatch_task_request.request_mut(), + Task::::Direct(robot_task_request) => robot_task_request.request_mut(), } } - pub fn robot(&self) -> String { + pub fn robot(&self) -> Affiliation { match self { - Task::Direct(robot_task_request) => robot_task_request.robot(), - _ => "".to_string(), + Task::::Direct(robot_task_request) => robot_task_request.robot(), + _ => Affiliation(None), } } - pub fn robot_mut(&mut self) -> Option<&mut String> { + pub fn robot_mut(&mut self) -> Option<&mut Affiliation> { match self { - Task::Direct(robot_task_request) => Some(robot_task_request.robot_mut()), + Task::::Direct(robot_task_request) => Some(robot_task_request.robot_mut()), _ => None, } } pub fn fleet(&self) -> String { match self { - Task::Direct(robot_task_request) => robot_task_request.fleet(), + Task::::Direct(robot_task_request) => robot_task_request.fleet(), _ => "".to_string(), } } pub fn fleet_mut(&mut self) -> Option<&mut String> { match self { - Task::Direct(robot_task_request) => Some(robot_task_request.fleet_mut()), + Task::::Direct(robot_task_request) => Some(robot_task_request.fleet_mut()), _ => None, } } @@ -319,26 +364,20 @@ pub trait TaskKind: Component + Serialize + DeserializeOwned { // Supported Task kinds #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[cfg_attr(feature = "bevy", derive(Component, Reflect))] -pub struct GoToPlace { - pub location: String, +pub struct GoToPlace { + pub location: Affiliation, } -impl Default for GoToPlace { +impl Default for GoToPlace { fn default() -> Self { Self { - location: String::new(), + location: Affiliation(None), } } } -impl fmt::Display for GoToPlace { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "{}", self.location) - } -} - #[cfg(feature = "bevy")] -impl TaskKind for GoToPlace { +impl TaskKind for GoToPlace { fn label() -> String { "Go To Place".to_string() }