From 438a87f06f65058ee998e4c65cc77e9e68d18374 Mon Sep 17 00:00:00 2001 From: vcoppe Date: Sat, 3 Oct 2026 19:08:56 +0200 Subject: [PATCH] progress --- gpx-rs/engine/src/core/gpx/chunk.rs | 2 +- gpx-rs/engine/src/core/gpx/segment.rs | 4 +- gpx-rs/engine/src/core/gpx/track.rs | 2 +- gpx-rs/engine/src/core/gpx/waypoint.rs | 2 +- gpx-rs/engine/src/engine/command/mod.rs | 2 +- .../src/engine/derived/coordinates_cache.rs | 149 +++++++++++++++ .../src/engine/derived/file_structure.rs | 140 ++++++++++++++ .../engine/derived/file_structure_cache.rs | 82 ++++++++ gpx-rs/engine/src/engine/derived/mod.rs | 6 + gpx-rs/engine/src/engine/engine.rs | 141 ++++++++++++-- gpx-rs/engine/src/engine/state/selection.rs | 2 +- gpx-rs/engine/src/engine/state/stack.rs | 3 +- gpx-rs/wasm/src/lib.rs | 180 +++++++++++++++++- 13 files changed, 686 insertions(+), 29 deletions(-) create mode 100644 gpx-rs/engine/src/engine/derived/coordinates_cache.rs create mode 100644 gpx-rs/engine/src/engine/derived/file_structure.rs create mode 100644 gpx-rs/engine/src/engine/derived/file_structure_cache.rs diff --git a/gpx-rs/engine/src/core/gpx/chunk.rs b/gpx-rs/engine/src/core/gpx/chunk.rs index 3fd661910..cf4c6b2a5 100644 --- a/gpx-rs/engine/src/core/gpx/chunk.rs +++ b/gpx-rs/engine/src/core/gpx/chunk.rs @@ -31,7 +31,7 @@ impl TrackpointChunk { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct WaypointChunkId(Uuid); impl Default for WaypointChunkId { diff --git a/gpx-rs/engine/src/core/gpx/segment.rs b/gpx-rs/engine/src/core/gpx/segment.rs index fe6ebdf83..a3c33c6df 100644 --- a/gpx-rs/engine/src/core/gpx/segment.rs +++ b/gpx-rs/engine/src/core/gpx/segment.rs @@ -5,7 +5,7 @@ use uuid::Uuid; use crate::{Trackpoint, TrackpointChunk}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct TrackSegmentId(Uuid); +pub struct TrackSegmentId(pub Uuid); impl Default for TrackSegmentId { fn default() -> Self { @@ -14,7 +14,7 @@ impl Default for TrackSegmentId { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct TrackSegmentRevisionId(Uuid); +pub struct TrackSegmentRevisionId(pub Uuid); impl Default for TrackSegmentRevisionId { fn default() -> Self { diff --git a/gpx-rs/engine/src/core/gpx/track.rs b/gpx-rs/engine/src/core/gpx/track.rs index 41cd4c381..5fe10454a 100644 --- a/gpx-rs/engine/src/core/gpx/track.rs +++ b/gpx-rs/engine/src/core/gpx/track.rs @@ -3,7 +3,7 @@ use uuid::Uuid; use crate::{Link, TrackSegment}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct TrackId(Uuid); +pub struct TrackId(pub Uuid); impl Default for TrackId { fn default() -> Self { diff --git a/gpx-rs/engine/src/core/gpx/waypoint.rs b/gpx-rs/engine/src/core/gpx/waypoint.rs index f4a65fdaa..e6c4c93cb 100644 --- a/gpx-rs/engine/src/core/gpx/waypoint.rs +++ b/gpx-rs/engine/src/core/gpx/waypoint.rs @@ -3,7 +3,7 @@ use uuid::Uuid; use crate::{Link, LngLat}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct WaypointId(Uuid); +pub struct WaypointId(pub Uuid); impl Default for WaypointId { fn default() -> Self { diff --git a/gpx-rs/engine/src/engine/command/mod.rs b/gpx-rs/engine/src/engine/command/mod.rs index ad2d90134..1356ca835 100644 --- a/gpx-rs/engine/src/engine/command/mod.rs +++ b/gpx-rs/engine/src/engine/command/mod.rs @@ -3,7 +3,7 @@ mod command; mod edit; mod file; #[cfg(test)] -mod fixture; +pub(crate) mod fixture; mod pattern; mod tools; diff --git a/gpx-rs/engine/src/engine/derived/coordinates_cache.rs b/gpx-rs/engine/src/engine/derived/coordinates_cache.rs new file mode 100644 index 000000000..bc8d35289 --- /dev/null +++ b/gpx-rs/engine/src/engine/derived/coordinates_cache.rs @@ -0,0 +1,149 @@ +use std::collections::{HashMap, HashSet}; + +use crate::{ + FileId, StackEntry as Files, TrackSegmentId, TrackSegmentRevisionId, waypoints_revision, +}; + +/// Coordinates buffers, as flat `[lng, lat, lng, lat, ...]` arrays so that they can be handed +/// to the UI without per-point calls: one per track segment (its trackpoints) and one per file +/// (its waypoints). A buffer is only rebuilt when the revision of its segment (or the waypoints +/// of its file) changes, so editing a segment does not touch the buffers of the others. +#[derive(Debug, Default)] +pub struct CoordinatesCache { + segments: HashMap)>, + waypoints: HashMap)>, +} + +impl CoordinatesCache { + pub fn update(&mut self, files: Option<&Files>) { + let mut segments = HashSet::new(); + let mut waypoints = HashSet::new(); + for file in files.into_iter().flat_map(|files| files.values()) { + waypoints.insert(file.id); + let rev = waypoints_revision(file); + if self.waypoints.get(&file.id).is_none_or(|(r, _)| *r != rev) { + let coordinates = file + .wpt + .iter() + .flat_map(|chunk| &chunk.wpt) + .flat_map(|wpt| [wpt.coordinates.lng, wpt.coordinates.lat]) + .collect(); + self.waypoints.insert(file.id, (rev, coordinates)); + } + for seg in file.trk.iter().flat_map(|trk| &trk.trkseg) { + segments.insert(seg.id); + if self + .segments + .get(&seg.id) + .is_none_or(|(r, _)| *r != seg.rev_id) + { + let coordinates = seg + .iter() + .flat_map(|trkpt| [trkpt.coordinates.lng, trkpt.coordinates.lat]) + .collect(); + self.segments.insert(seg.id, (seg.rev_id, coordinates)); + } + } + } + self.segments.retain(|id, _| segments.contains(id)); + self.waypoints.retain(|id, _| waypoints.contains(id)); + } + + /// Coordinates of the trackpoints of a segment, empty if it does not exist. + pub fn segment(&self, id: &TrackSegmentId) -> &[f64] { + self.segments.get(id).map_or(&[], |(_, c)| c) + } + + /// Coordinates of the waypoints of a file, empty if it does not exist. + pub fn waypoints(&self, id: &FileId) -> &[f64] { + self.waypoints.get(id).map_or(&[], |(_, c)| c) + } +} + +#[cfg(test)] +mod tests { + use std::rc::Rc; + + use crate::{Apply, LngLat, Load, Waypoint, WaypointChunk, engine::command::fixture::Fixture}; + + use super::*; + + fn loaded() -> (Fixture, FileId) { + let mut fx = Fixture::default(); + let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap(); + Load { data: &data }.apply(&mut fx.state()).unwrap(); + let id = fx.order.0[0]; + (fx, id) + } + + #[test] + fn test_segment_buffers_match_trackpoints() { + let (fx, id) = loaded(); + let mut cache = CoordinatesCache::default(); + cache.update(Some(&fx.files)); + for seg in fx.files[&id].trk.iter().flat_map(|t| &t.trkseg) { + let buffer = cache.segment(&seg.id); + assert_eq!(buffer.len(), seg.len() * 2); + for (i, p) in seg.iter().enumerate() { + assert_eq!(buffer[2 * i], p.coordinates.lng); + assert_eq!(buffer[2 * i + 1], p.coordinates.lat); + } + } + assert!(cache.segment(&TrackSegmentId::default()).is_empty()); + } + + #[test] + fn test_only_changed_buffers_are_rebuilt_and_stale_ones_dropped() { + let (mut fx, id) = loaded(); + let mut cache = CoordinatesCache::default(); + cache.update(Some(&fx.files)); + let ids: Vec<_> = fx.files[&id] + .trk + .iter() + .flat_map(|t| &t.trkseg) + .map(|s| s.id) + .collect(); + assert!(ids.len() >= 2); + let ptr = |c: &CoordinatesCache, i: usize| c.segment(&ids[i]).as_ptr(); + let (p0, p1) = (ptr(&cache, 0), ptr(&cache, 1)); + + let mut file = (*fx.files[&id]).clone(); + let seg = &mut file.trk[0].trkseg[0]; + seg.splice(0, 0, vec![Default::default()]); + seg.rev_id = Default::default(); + let len = seg.len(); + fx.files.insert(id, Rc::new(file)); + cache.update(Some(&fx.files)); + assert_eq!(cache.segment(&ids[0]).len(), len * 2); + assert_ne!(ptr(&cache, 0), p0); + assert_eq!(ptr(&cache, 1), p1); + + cache.update(None); + assert!(cache.segment(&ids[0]).is_empty()); + assert!(cache.waypoints(&id).is_empty()); + } + + #[test] + fn test_waypoint_buffer() { + let (mut fx, id) = loaded(); + let wpt = |lng, lat| Waypoint { + coordinates: LngLat { lng, lat }, + ..Default::default() + }; + let mut file = (*fx.files[&id]).clone(); + file.wpt = vec![ + Rc::new(WaypointChunk { + wpt: vec![wpt(1.0, 2.0)], + ..Default::default() + }), + Rc::new(WaypointChunk { + wpt: vec![wpt(3.0, 4.0)], + ..Default::default() + }), + ]; + fx.files.insert(id, Rc::new(file)); + let mut cache = CoordinatesCache::default(); + cache.update(Some(&fx.files)); + assert_eq!(cache.waypoints(&id), [1.0, 2.0, 3.0, 4.0]); + } +} diff --git a/gpx-rs/engine/src/engine/derived/file_structure.rs b/gpx-rs/engine/src/engine/derived/file_structure.rs new file mode 100644 index 000000000..8ca959852 --- /dev/null +++ b/gpx-rs/engine/src/engine/derived/file_structure.rs @@ -0,0 +1,140 @@ +use std::hash::{Hash, Hasher}; + +use crate::{File, FileId, TrackId, TrackSegmentId, TrackSegmentRevisionId, WaypointId}; + +/// What the UI needs to display a file: its name and the structure of its tracks and waypoints, +/// with the ids that reference every element. Coordinates are not part of it, they are read +/// from the buffers of the [`CoordinatesCache`](crate::CoordinatesCache) (the `i`-th waypoint +/// of a file is the `i`-th coordinates pair of its waypoint buffer, same for the trackpoints of +/// a segment). +#[derive(Debug, Clone, PartialEq)] +pub struct FileStructure { + pub id: FileId, + pub name: String, + pub tracks: Vec, + pub waypoints: Vec, + /// Changes whenever the waypoints of the file (hence their buffer) change. + pub waypoints_rev: u64, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct TrackNode { + pub id: TrackId, + pub name: Option, + pub segments: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct SegmentNode { + pub id: TrackSegmentId, + /// Changes whenever the trackpoints of the segment (hence their buffer) change. + pub rev_id: TrackSegmentRevisionId, + pub len: usize, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct WaypointNode { + pub id: WaypointId, + pub name: Option, +} + +impl FileStructure { + pub fn new(file: &File) -> Self { + Self { + id: file.id, + name: file.info.name.clone(), + tracks: file + .trk + .iter() + .map(|trk| TrackNode { + id: trk.id, + name: trk.info.name.clone(), + segments: trk + .trkseg + .iter() + .map(|seg| SegmentNode { + id: seg.id, + rev_id: seg.rev_id, + len: seg.len(), + }) + .collect(), + }) + .collect(), + waypoints: file + .wpt + .iter() + .flat_map(|chunk| &chunk.wpt) + .map(|wpt| WaypointNode { + id: wpt.id, + name: wpt.name.clone(), + }) + .collect(), + waypoints_rev: waypoints_revision(file), + } + } +} + +/// Identifies the current waypoints of a file: chunks are immutable, so their ids are enough. +pub fn waypoints_revision(file: &File) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + for chunk in &file.wpt { + chunk.id.hash(&mut hasher); + } + hasher.finish() +} + +#[cfg(test)] +mod tests { + use std::rc::Rc; + + use crate::{Apply, Load, Waypoint, WaypointChunk, engine::command::fixture::Fixture}; + + use super::*; + + #[test] + fn test_structure_of_a_file() { + let mut fx = Fixture::default(); + let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap(); + Load { data: &data }.apply(&mut fx.state()).unwrap(); + let file = &fx.files[&fx.order.0[0]]; + let node = FileStructure::new(file); + assert_eq!((node.id, &node.name), (file.id, &file.info.name)); + assert_eq!(node.tracks.len(), file.trk.len()); + for (n, t) in node.tracks.iter().zip(&file.trk) { + assert_eq!((n.id, &n.name), (t.id, &t.info.name)); + assert_eq!(n.segments.len(), t.trkseg.len()); + for (n, s) in n.segments.iter().zip(&t.trkseg) { + assert_eq!((n.id, n.rev_id, n.len), (s.id, s.rev_id, s.len())); + } + } + } + + #[test] + fn test_waypoints_in_buffer_order_and_revision() { + let mut file = crate::File::default(); + let wpt = |n: &str| Waypoint { + name: Some(n.to_string()), + ..Default::default() + }; + let before = waypoints_revision(&file); + file.wpt = vec![ + Rc::new(WaypointChunk { + wpt: vec![wpt("a"), wpt("b")], + ..Default::default() + }), + Rc::new(WaypointChunk { + wpt: vec![wpt("c")], + ..Default::default() + }), + ]; + let node = FileStructure::new(&file); + let names: Vec<_> = node + .waypoints + .iter() + .map(|w| w.name.clone().unwrap()) + .collect(); + assert_eq!(names, ["a", "b", "c"]); + assert_ne!(node.waypoints_rev, before); + assert_eq!(node.waypoints_rev, waypoints_revision(&file)); + } +} diff --git a/gpx-rs/engine/src/engine/derived/file_structure_cache.rs b/gpx-rs/engine/src/engine/derived/file_structure_cache.rs new file mode 100644 index 000000000..20a3be0f4 --- /dev/null +++ b/gpx-rs/engine/src/engine/derived/file_structure_cache.rs @@ -0,0 +1,82 @@ +use std::collections::HashMap; + +use crate::{Diff, FileId, FileStructure, StackEntry as Files}; + +/// The structure of each file, only recomputed for the files that changed according to the +/// [`Diff`] of the stack. +#[derive(Debug, Default)] +pub struct FileStructureCache { + map: HashMap, +} + +impl FileStructureCache { + /// Brings the cache in line with `files`, given what changed since the last update. + pub fn update(&mut self, files: Option<&Files>, diff: &Option) { + if let Some(diff) = diff { + for id in diff.removed.iter() { + self.map.remove(id); + } + for id in diff.added.iter().chain(&diff.modified) { + if let Some(file) = files.and_then(|files| files.get(id)) { + self.map.insert(*id, FileStructure::new(file)); + } + } + } + } + + pub fn get(&self, id: &FileId) -> Option<&FileStructure> { + self.map.get(id) + } +} + +#[cfg(test)] +mod tests { + use std::rc::Rc; + + use crate::{Apply, Load, engine::command::fixture::Fixture}; + + use super::*; + + #[test] + fn test_only_files_of_the_diff_are_recomputed() { + let mut fx = Fixture::default(); + let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap(); + Load { data: &data }.apply(&mut fx.state()).unwrap(); + Load { data: &data }.apply(&mut fx.state()).unwrap(); + let (a, b) = (fx.order.0[0], fx.order.0[1]); + + let mut cache = FileStructureCache::default(); + cache.update( + Some(&fx.files), + &Some(Diff { + added: vec![a, b], + ..Default::default() + }), + ); + let before = cache.get(&b).unwrap() as *const FileStructure; + + let mut file = (*fx.files[&a]).clone(); + file.info.name = "renamed".into(); + fx.files.insert(a, Rc::new(file)); + cache.update( + Some(&fx.files), + &Some(Diff { + modified: vec![a], + ..Default::default() + }), + ); + assert_eq!(cache.get(&a).unwrap().name, "renamed"); + assert!(std::ptr::eq(cache.get(&b).unwrap(), before)); + + fx.files.remove(&b); + cache.update( + Some(&fx.files), + &Some(Diff { + removed: vec![b], + ..Default::default() + }), + ); + assert!(cache.get(&b).is_none()); + assert!(cache.get(&a).is_some()); + } +} diff --git a/gpx-rs/engine/src/engine/derived/mod.rs b/gpx-rs/engine/src/engine/derived/mod.rs index d4df4a7d3..953aa22f6 100644 --- a/gpx-rs/engine/src/engine/derived/mod.rs +++ b/gpx-rs/engine/src/engine/derived/mod.rs @@ -1,5 +1,11 @@ +mod coordinates_cache; +mod file_structure; +mod file_structure_cache; mod statistics_buffer; mod statistics_cache; +pub use coordinates_cache::*; +pub use file_structure::*; +pub use file_structure_cache::*; pub use statistics_buffer::*; pub use statistics_cache::*; diff --git a/gpx-rs/engine/src/engine/engine.rs b/gpx-rs/engine/src/engine/engine.rs index 8c0988499..9a6e8c319 100644 --- a/gpx-rs/engine/src/engine/engine.rs +++ b/gpx-rs/engine/src/engine/engine.rs @@ -3,8 +3,8 @@ use std::collections::HashSet; use crate::{ - Action, Apply, Command, FileId, FileOrder, Selection, Stack, State, StatisticsBuffer, - StatisticsCache, + Action, Apply, Command, CoordinatesCache, Diff, FileId, FileOrder, FileStructure, + FileStructureCache, Selection, Stack, State, StatisticsBuffer, StatisticsCache, TrackSegmentId, }; #[derive(Debug, Default)] @@ -13,6 +13,10 @@ pub struct Engine { selection: Selection, order: FileOrder, statistics_cache: StatisticsCache, + coordinates_cache: CoordinatesCache, + structure_cache: FileStructureCache, + diff: Option, + order_changed: bool, statistics_buffer: StatisticsBuffer, } @@ -21,18 +25,51 @@ impl Engine { &self.statistics_buffer } + /// Files in display order. + pub fn order(&self) -> &[FileId] { + &self.order.0 + } + + /// Name, tracks, segments and waypoints (with their ids) of a file. + pub fn file_structure(&self, id: &FileId) -> Option<&FileStructure> { + self.structure_cache.get(id) + } + + /// Which files the last action added, removed or modified. + pub fn last_diff(&self) -> &Option { + &self.diff + } + + /// Whether the last action changed the order of the files. + pub fn order_changed(&self) -> bool { + self.order_changed + } + + /// Coordinates (`[lng, lat, ...]`) of the trackpoints of a segment. + pub fn segment_coordinates(&self, id: &TrackSegmentId) -> &[f64] { + self.coordinates_cache.segment(id) + } + + /// Coordinates (`[lng, lat, ...]`) of the waypoints of a file. + pub fn waypoint_coordinates(&self, id: &FileId) -> &[f64] { + self.coordinates_cache.waypoints(id) + } + /// Single entry point for every action coming from the frontend. Returns whether anything /// changed. pub fn execute(&mut self, action: Action) -> bool { - let changed = match action { + let selection_before = self.selection.clone(); + let order_before = self.order.0.clone(); + // the files change iff there is a diff, but selection and order actions have none + self.diff = match action { Action::Edit(command) => self.edit(command), - Action::Undo => self.stack.undo().is_some(), - Action::Redo => self.stack.redo().is_some(), + Action::Undo => self.stack.undo(), + Action::Redo => self.stack.redo(), Action::Select { file_ids } => { self.selection = Selection::File { file_ids: file_ids.into_iter().collect(), }; - true + None } Action::AddSelect { file_ids } => { match &mut self.selection { @@ -43,33 +80,37 @@ impl Engine { } } } - true + None + } + Action::Reorder { file_ids, index } => { + self.order.move_files(&file_ids, index); + None } - Action::Reorder { file_ids, index } => self.order.move_files(&file_ids, index), Action::SelectAll => { self.selection = Selection::File { file_ids: self.order.0.iter().copied().collect(), }; - true + None } }; + let selection_changed = self.selection != selection_before; + self.order_changed = self.order.0 != order_before; + let changed = self.diff.is_some() || selection_changed || self.order_changed; if changed { self.refresh(); } changed } - fn edit(&mut self, command: Command) -> bool { - self.stack - .create_and_push_next(|files| { - let mut state = State { - files, - selection: &mut self.selection, - order: &mut self.order, - }; - command.apply(&mut state).map_err(|err| err.to_string()) - }) - .is_some() + fn edit(&mut self, command: Command) -> Option { + self.stack.create_and_push_next(|files| { + let mut state = State { + files, + selection: &mut self.selection, + order: &mut self.order, + }; + command.apply(&mut state).map_err(|err| err.to_string()) + }) } /// Brings everything derived from the files back in line with the current stack entry. @@ -87,6 +128,8 @@ impl Engine { } } self.statistics_cache.update(current); + self.coordinates_cache.update(current); + self.structure_cache.update(current, &self.diff); self.statistics_buffer .update(&self.statistics_cache.get(current, &self.selection)); } @@ -280,6 +323,64 @@ mod tests { assert_eq!(engine.order.0, vec![a, b, c]); } + #[test] + fn test_structures_and_coordinates_follow_actions() { + let mut engine = Engine::default(); + assert!(engine.order().is_empty()); + load(&mut engine, "data/simple.gpx"); + let loaded_id = engine.order()[0]; + let diff = engine.last_diff(); + assert!(diff.is_some()); + let diff = diff.as_ref().unwrap(); + assert_eq!(diff.added, vec![loaded_id]); + assert!(engine.order_changed()); + + new(&mut engine, "empty"); + let empty_id = engine.order()[1]; + // only the new file is reported, the other one is not recomputed + let diff = engine.last_diff(); + assert!(diff.is_some()); + let diff = diff.as_ref().unwrap(); + assert_eq!(diff.added, vec![empty_id]); + assert!(diff.modified.is_empty() && diff.removed.is_empty()); + assert_eq!(engine.file_structure(&empty_id).unwrap().name, "empty"); + + let structure = engine.file_structure(&loaded_id).unwrap(); + let seg = &structure.tracks[0].segments[0]; + let (seg_id, len) = (seg.id, seg.len); + assert_eq!(engine.segment_coordinates(&seg_id).len(), len * 2); + assert!(engine.waypoint_coordinates(&loaded_id).is_empty()); + + // reordering only changes the order + assert!(engine.execute(Action::Reorder { + file_ids: vec![loaded_id], + index: 1 + })); + assert_eq!(engine.order(), [empty_id, loaded_id]); + assert!(engine.last_diff().is_none()); + assert!(engine.order_changed()); + + // an action that changes nothing reports nothing + assert!(!engine.execute(Action::Reorder { + file_ids: vec![loaded_id], + index: 1 + })); + assert!(engine.last_diff().is_none()); + assert!(!engine.order_changed()); + + // undo removes the empty file, redoing the load removes the other one too + assert!(engine.execute(Action::Undo)); + let diff = engine.last_diff(); + assert!(diff.is_some()); + let diff = diff.as_ref().unwrap(); + assert_eq!(diff.removed, vec![empty_id]); + assert!(engine.file_structure(&empty_id).is_none()); + assert!(engine.execute(Action::Undo)); + assert!(engine.order().is_empty()); + assert!(engine.file_structure(&loaded_id).is_none()); + assert!(engine.segment_coordinates(&seg_id).is_empty()); + } + #[test] fn test_undo_updates_statistics() { let mut engine = Engine::default(); diff --git a/gpx-rs/engine/src/engine/state/selection.rs b/gpx-rs/engine/src/engine/state/selection.rs index a2dc03118..0f4d792ad 100644 --- a/gpx-rs/engine/src/engine/state/selection.rs +++ b/gpx-rs/engine/src/engine/state/selection.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; use crate::{FileId, StackEntry, TrackId, TrackSegmentId, WaypointId}; -#[derive(Debug, Default)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] pub enum Selection { #[default] Empty, diff --git a/gpx-rs/engine/src/engine/state/stack.rs b/gpx-rs/engine/src/engine/state/stack.rs index e403e6ad7..1e2e3a8e4 100644 --- a/gpx-rs/engine/src/engine/state/stack.rs +++ b/gpx-rs/engine/src/engine/state/stack.rs @@ -117,7 +117,8 @@ impl Stack { pub type StackEntry = HashMap>; -#[derive(Debug, Default)] +/// Which files were added, removed or modified by a change of the current stack entry. +#[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct Diff { pub added: Vec, pub removed: Vec, diff --git a/gpx-rs/wasm/src/lib.rs b/gpx-rs/wasm/src/lib.rs index bec6f55e2..195d054b1 100644 --- a/gpx-rs/wasm/src/lib.rs +++ b/gpx-rs/wasm/src/lib.rs @@ -6,13 +6,15 @@ //! - File bytes cross as `Uint8Array` (`&[u8]`), strings as `&str`. //! - File ids cross as one flat `Uint8Array` of concatenated 16-byte UUIDs (no `Array`). //! - Rectangles cross as four numbers instead of an object. +//! - Ids read from the file tree are hyphenated UUID strings; the functions reading buffers take +//! them as such. //! - Every function returns `false` when its arguments are invalid or the command did nothing. use std::cell::RefCell; use wasm_bindgen::prelude::*; use gpx_engine::{self as engine, Action, Command, Engine, FileId, LngLat, LngLatBounds}; -use js_sys::{Float64Array, Int32Array}; +use js_sys::{Array, Float64Array, Int32Array, Object, Reflect}; #[wasm_bindgen] #[derive(Clone, Copy)] @@ -319,3 +321,179 @@ pub fn add_select(file_ids_bytes: &[u8]) -> bool { pub fn select_all() -> bool { execute(Action::SelectAll) } + +// File order and structures +// +// The files are described by one ordered list of ids and one structure object per file, so that +// the UI can react to what changed only: after each action, `last_update` tells which +// structures to (re)read or drop and whether the order changed. Ids are UUID strings. The +// trackpoints of a segment and the waypoints of a file are in the same order as the coordinates +// of their buffers (see below). + +#[wasm_bindgen(typescript_custom_section)] +const FILE_STRUCTURE_TS: &str = r#" +export interface FilesUpdate { + orderChanged: boolean; + /** Files to read the structure of. */ + added: string[]; + /** Files whose structure changed: reread it. */ + modified: string[]; + /** Files that do not exist anymore: drop their structure. */ + removed: string[]; +} +export interface FileStructure { + id: string; + name: string; + tracks: TrackNode[]; + waypoints: WaypointNode[]; + /** Changes when the waypoints of the file change: refetch their coordinates. */ + waypointsRev: string; +} +export interface TrackNode { + id: string; + name?: string; + segments: SegmentNode[]; +} +export interface SegmentNode { + id: string; + /** Changes when the trackpoints of the segment change: refetch their coordinates. */ + rev: string; + /** Number of trackpoints. */ + length: number; +} +export interface WaypointNode { + id: string; + name?: string; +} +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "FilesUpdate")] + pub type FilesUpdate; + #[wasm_bindgen(typescript_type = "FileStructure | undefined")] + pub type FileStructure; + #[wasm_bindgen(typescript_type = "string[]")] + pub type FileOrder; +} + +fn set(object: &Object, key: &str, value: impl Into) { + Reflect::set(object, &key.into(), &value.into()).unwrap(); +} + +fn array(items: &[T], f: impl Fn(&T) -> JsValue) -> Array { + items.iter().map(f).collect() +} + +fn ids(items: &[FileId]) -> Array { + array(items, |id| id.0.to_string().into()) +} + +fn named_node(id: uuid::Uuid, name: Option<&str>) -> Object { + let node = Object::new(); + set(&node, "id", id.to_string()); + if let Some(name) = name { + set(&node, "name", name); + } + node +} + +fn structure_object(file: &engine::FileStructure) -> Object { + let node = named_node(file.id.0, Some(&file.name)); + set( + &node, + "tracks", + array(&file.tracks, |trk| { + let node = named_node(trk.id.0, trk.name.as_deref()); + set( + &node, + "segments", + array(&trk.segments, |seg| { + let node = named_node(seg.id.0, None); + set(&node, "rev", seg.rev_id.0.to_string()); + set(&node, "length", seg.len as f64); + node.into() + }), + ); + node.into() + }), + ); + set( + &node, + "waypoints", + array(&file.waypoints, |wpt| { + named_node(wpt.id.0, wpt.name.as_deref()).into() + }), + ); + set(&node, "waypointsRev", format!("{:x}", file.waypoints_rev)); + node +} + +fn with_engine(f: impl FnOnce(&Engine) -> T) -> Option { + ENGINE.with(|engine| engine.borrow().as_ref().map(f)) +} + +/// Ids of the files in display order. +#[wasm_bindgen] +pub fn file_order() -> FileOrder { + let order = with_engine(|e| ids(e.order())).unwrap_or_default(); + order.unchecked_into() +} + +/// Structure of a file, `undefined` if the id is unknown. +#[wasm_bindgen] +pub fn file_structure(file_id: &str) -> FileStructure { + let structure = uuid::Uuid::parse_str(file_id) + .ok() + .and_then(|id| { + with_engine(|e| e.file_structure(&FileId(id)).map(structure_object)).flatten() + }) + .map_or(JsValue::UNDEFINED, JsValue::from); + structure.unchecked_into() +} + +/// What the last action changed. Read it right after each action. +#[wasm_bindgen] +pub fn last_update() -> FilesUpdate { + let update = Object::new(); + with_engine(|e| { + let diff = e.last_diff().clone().unwrap_or_default(); + set(&update, "orderChanged", e.order_changed()); + set(&update, "added", ids(&diff.added)); + set(&update, "modified", ids(&diff.modified)); + set(&update, "removed", ids(&diff.removed)); + }); + update.unchecked_into() +} + +// Coordinates buffers +// +// Like the statistics buffers, these are views into wasm memory, invalidated by the next +// command (and any allocation): read or copy them right away. They are flat `[lng, lat, ...]` +// arrays. Compare `rev` / `waypointsRev` of the file structures with the previous ones to know which +// buffers actually changed. + +fn coordinates_view(f: impl FnOnce(&Engine) -> Option<&[f64]>) -> Float64Array { + ENGINE.with(|engine| match engine.borrow().as_ref().and_then(f) { + Some(coordinates) => unsafe { Float64Array::view(coordinates) }, + None => Float64Array::new_with_length(0), + }) +} + +/// Coordinates of the trackpoints of a segment (empty if the id is unknown). +#[wasm_bindgen] +pub fn segment_coordinates(segment_id: &str) -> Float64Array { + match uuid::Uuid::parse_str(segment_id) { + Ok(id) => coordinates_view(|e| Some(e.segment_coordinates(&engine::TrackSegmentId(id)))), + Err(_) => Float64Array::new_with_length(0), + } +} + +/// Coordinates of the waypoints of a file (empty if the id is unknown). +#[wasm_bindgen] +pub fn waypoint_coordinates(file_id: &str) -> Float64Array { + match uuid::Uuid::parse_str(file_id) { + Ok(id) => coordinates_view(|e| Some(e.waypoint_coordinates(&FileId(id)))), + Err(_) => Float64Array::new_with_length(0), + } +}