diff --git a/gpx-rs/engine/src/core/gpx/segment.rs b/gpx-rs/engine/src/core/gpx/segment.rs index f06aeb30f..fe6ebdf83 100644 --- a/gpx-rs/engine/src/core/gpx/segment.rs +++ b/gpx-rs/engine/src/core/gpx/segment.rs @@ -32,12 +32,77 @@ pub struct TrackSegment { impl TrackSegment { pub fn push(&mut self, chunk: TrackpointChunk) { + self.push_shared(Rc::new(chunk)); + } + + fn push_shared(&mut self, chunk: Rc) { if chunk.trkpt.is_empty() { return; } self.cumul_length .push(self.cumul_length.last().copied().unwrap_or_default() + chunk.trkpt.len()); - self.chunks.push(Rc::new(chunk)); + self.chunks.push(chunk); + } + + /// Replaces the points in `start..end` by `points`. Panics if the range is out of bounds. + /// + /// Chunks that are not concerned are kept as they are (shared), only the chunks around the + /// range are copied and refilled. + pub fn splice(&mut self, start: usize, end: usize, points: Vec) { + assert!( + start <= end && end <= self.len(), + "splice range out of bounds" + ); + let old = std::mem::take(&mut self.chunks); + self.cumul_length.clear(); + + let mut pending = TrackpointChunk::default(); + let mut inserted = false; + let mut offset = 0; + for chunk in old { + let (lo, hi) = (offset, offset + chunk.trkpt.len()); + offset = hi; + // a chunk ending at `start` is extended unless it is full, to avoid tiny chunks + if hi < start || (hi == start && chunk.is_full()) { + self.push_shared(chunk); + continue; + } + if !inserted { + self.fill(&mut pending, chunk.trkpt[..start - lo].iter().cloned()); + self.fill(&mut pending, points.iter().cloned()); + inserted = true; + } + if hi <= end { + continue; + } + if lo >= end { + self.flush(&mut pending); + self.push_shared(chunk); + } else { + self.fill(&mut pending, chunk.trkpt[end - lo..].iter().cloned()); + } + } + if !inserted { + self.fill(&mut pending, points); + } + self.flush(&mut pending); + } + + fn fill( + &mut self, + pending: &mut TrackpointChunk, + points: impl IntoIterator, + ) { + for trkpt in points { + pending.trkpt.push(trkpt); + if pending.is_full() { + self.flush(pending); + } + } + } + + fn flush(&mut self, pending: &mut TrackpointChunk) { + self.push(std::mem::take(pending)); } pub fn len(&self) -> usize { @@ -193,6 +258,81 @@ mod tests { trkseg } + fn eles(trkseg: &TrackSegment) -> Vec { + trkseg.iter().map(|p| p.ele).collect() + } + + fn points(eles: &[f64]) -> Vec { + eles.iter() + .map(|&ele| Trackpoint { + ele, + ..Default::default() + }) + .collect() + } + + #[test] + fn test_splice_replace_insert_delete_append() { + // chunks of 1..=5 points: [0] [1 2] [3 4 5] [6 7 8 9] [10..=14] + let mut trkseg = create_track_segment(5); + trkseg.splice(4, 8, points(&[-1.0, -2.0])); + let mut expected: Vec = (0..4).map(f64::from).collect(); + expected.extend([-1.0, -2.0]); + expected.extend((8..15).map(f64::from)); + assert_eq!(eles(&trkseg), expected); + assert_eq!(trkseg.len(), expected.len()); + for i in 0..trkseg.len() { + assert_eq!(trkseg[i].ele, expected[i]); + } + + trkseg.splice(2, 2, points(&[100.0])); + expected.insert(2, 100.0); + assert_eq!(eles(&trkseg), expected); + + trkseg.splice(0, 3, vec![]); + expected.drain(..3); + assert_eq!(eles(&trkseg), expected); + + let len = trkseg.len(); + trkseg.splice(len, len, points(&[7.0, 8.0])); + expected.extend([7.0, 8.0]); + assert_eq!(eles(&trkseg), expected); + + let len = trkseg.len(); + trkseg.splice(0, len, vec![]); + assert_eq!(trkseg.len(), 0); + assert!(trkseg.first_index().is_none()); + } + + #[test] + fn test_splice_keeps_untouched_chunks_shared() { + let mut trkseg = create_track_segment(5); + let before = trkseg.chunks.clone(); + // inside the third chunk only + trkseg.splice(4, 5, points(&[-1.0])); + assert!(Rc::ptr_eq(&trkseg.chunks[0], &before[0])); + assert!(Rc::ptr_eq(&trkseg.chunks[1], &before[1])); + assert!(Rc::ptr_eq(trkseg.chunks.last().unwrap(), &before[4])); + assert!(Rc::ptr_eq( + &trkseg.chunks[trkseg.chunks.len() - 2], + &before[3] + )); + } + + #[test] + fn test_splice_append_extends_last_chunk_and_respects_max_size() { + let mut trkseg = TrackSegment::default(); + for i in 0..10_000 { + let len = trkseg.len(); + trkseg.splice(len, len, points(&[i as f64])); + } + assert_eq!(trkseg.len(), 10_000); + assert!(trkseg.chunks.len() <= 3); + assert!(trkseg.chunks.iter().all(|c| c.trkpt.len() <= 4096)); + assert_eq!(trkseg[9_999].ele, 9_999.0); + assert_eq!(*trkseg.cumul_length.last().unwrap(), 10_000); + } + #[test] fn test_len() { let nb_chunks = 10; diff --git a/gpx-rs/engine/src/core/gpx/trackpoint.rs b/gpx-rs/engine/src/core/gpx/trackpoint.rs index 32ee7a23a..f724df429 100644 --- a/gpx-rs/engine/src/core/gpx/trackpoint.rs +++ b/gpx-rs/engine/src/core/gpx/trackpoint.rs @@ -1,6 +1,6 @@ use crate::LngLat; -#[derive(Debug, Default)] +#[derive(Debug, Default, Clone)] pub struct Trackpoint { pub coordinates: LngLat, pub ele: f64, diff --git a/gpx-rs/engine/src/engine/command/tools/splice_trackpoints.rs b/gpx-rs/engine/src/engine/command/tools/splice_trackpoints.rs index 18207a574..e45cdfc75 100644 --- a/gpx-rs/engine/src/engine/command/tools/splice_trackpoints.rs +++ b/gpx-rs/engine/src/engine/command/tools/splice_trackpoints.rs @@ -1,5 +1,13 @@ -use crate::{Apply, CommandError, State}; +use std::rc::Rc; +use crate::{Apply, CommandError, File, FileId, LngLat, Selection, State, Trackpoint}; + +/// Replaces the trackpoints `start..end` of the last track segment of the selection by the +/// given ones (a pure insertion when `start == end`, a pure removal when there are no new +/// points). +/// +/// The selection can be made of several files, tracks or track segments: the indices are +/// meant for its very last segment, in file order, then track order, then segment order. #[derive(Debug)] pub struct SpliceTrackpoints<'a> { pub start: u32, @@ -10,8 +18,288 @@ pub struct SpliceTrackpoints<'a> { } impl Apply for SpliceTrackpoints<'_> { - fn apply(self, _state: &mut State) -> Result<(), CommandError> { - // TODO - Err(CommandError::NotImplemented("splice_trackpoints")) + fn apply(self, state: &mut State) -> Result<(), CommandError> { + let (start, end) = (self.start as usize, self.end as usize); + if start > end || self.lng.len() != self.lat.len() || self.lat.len() != self.ele.len() { + return Err(CommandError::InvalidData( + "invalid trackpoints range".into(), + )); + } + let (file_id, trk, seg) = last_segment(state).ok_or(CommandError::NothingToDo)?; + let mut file = (*state.files[&file_id]).clone(); + let segment = &mut file.trk[trk].trkseg[seg]; + if end > segment.len() { + return Err(CommandError::InvalidData( + "trackpoints range out of bounds".into(), + )); + } + if start == end && self.lng.is_empty() { + return Err(CommandError::NothingToDo); + } + let points = (0..self.lng.len()) + .map(|i| Trackpoint { + coordinates: LngLat { + lng: self.lng[i], + lat: self.lat[i], + }, + ele: self.ele[i], + ..Default::default() + }) + .collect(); + segment.splice(start, end, points); + segment.rev_id = Default::default(); + state.files.insert(file_id, Rc::new(file)); + Ok(()) + } +} + +/// Position (file, track index, segment index) of the last segment of the selection. +fn last_segment(state: &State) -> Option<(FileId, usize, usize)> { + let last_of_track = |file: &File, trk: usize| file.trk[trk].trkseg.len().checked_sub(1); + let last_of_file = |file: &File, filter: &dyn Fn(usize) -> bool| { + (0..file.trk.len()) + .rev() + .filter(|&trk| filter(trk)) + .find_map(|trk| last_of_track(file, trk).map(|seg| (trk, seg))) + }; + match &*state.selection { + Selection::File { file_ids } => state + .order + .0 + .iter() + .rev() + .filter(|id| file_ids.contains(id)) + .find_map(|id| { + let (trk, seg) = last_of_file(state.files.get(id)?, &|_| true)?; + Some((*id, trk, seg)) + }), + Selection::Track { file_id, trk_ids } => { + let file = state.files.get(file_id)?; + let (trk, seg) = last_of_file(file, &|trk| trk_ids.contains(&file.trk[trk].id))?; + Some((*file_id, trk, seg)) + } + Selection::TrackSegment { + file_id, + trk_id, + trkseg_ids, + } => { + let file = state.files.get(file_id)?; + let trk = file.trk.iter().position(|trk| trk.id == *trk_id)?; + let seg = file.trk[trk] + .trkseg + .iter() + .rposition(|seg| trkseg_ids.contains(&seg.id))?; + Some((*file_id, trk, seg)) + } + Selection::Empty | Selection::Waypoints { .. } | Selection::Waypoint { .. } => None, + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use crate::{FileId, Load, 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]; + let trk = &fx.files[&id].trk[0]; + fx.selection = Selection::TrackSegment { + file_id: id, + trk_id: trk.id, + trkseg_ids: HashSet::from([trk.trkseg[0].id]), + }; + (fx, id) + } + + fn splice<'a>( + start: u32, + end: u32, + lng: &'a [f64], + lat: &'a [f64], + ele: &'a [f64], + ) -> SpliceTrackpoints<'a> { + SpliceTrackpoints { + start, + end, + lng, + lat, + ele, + } + } + + #[test] + fn test_splice_replaces_points_of_selected_segment_only() { + let (mut fx, id) = loaded(); + let before = fx.files[&id].clone(); + let len = before.trk[0].trkseg[0].len(); + assert!(len >= 3); + + splice(1, 3, &[1.0, 2.0, 3.0], &[4.0, 5.0, 6.0], &[7.0, 8.0, 9.0]) + .apply(&mut fx.state()) + .unwrap(); + + let after = &fx.files[&id]; + let (old, new) = (&before.trk[0].trkseg[0], &after.trk[0].trkseg[0]); + assert_eq!(new.len(), len + 1); + assert_eq!(new[0].coordinates.lng, old[0].coordinates.lng); + assert_eq!(new[1].coordinates.lng, 1.0); + assert_eq!(new[3].coordinates.lat, 6.0); + assert_eq!(new[3].ele, 9.0); + assert_eq!(new[4].coordinates.lng, old[3].coordinates.lng); + assert_ne!(new.rev_id, old.rev_id); + assert_eq!(new.id, old.id); + // other segments and tracks keep their revision + for (b, a) in before.trk.iter().zip(&after.trk) { + for (b, a) in b.trkseg.iter().zip(&a.trkseg) { + if a.id != new.id { + assert_eq!(a.rev_id, b.rev_id); + } + } + } + } + + #[test] + fn test_splice_append_and_remove() { + let (mut fx, id) = loaded(); + let len = fx.files[&id].trk[0].trkseg[0].len() as u32; + splice(len, len, &[1.0], &[2.0], &[3.0]) + .apply(&mut fx.state()) + .unwrap(); + let seg = &fx.files[&id].trk[0].trkseg[0]; + assert_eq!(seg.len(), len as usize + 1); + assert_eq!(seg[len as usize].ele, 3.0); + + splice(0, len + 1, &[], &[], &[]) + .apply(&mut fx.state()) + .unwrap(); + assert_eq!(fx.files[&id].trk[0].trkseg[0].len(), 0); + } + + #[test] + fn test_splice_invalid_arguments_change_nothing() { + let (mut fx, id) = loaded(); + let before = fx.files[&id].clone(); + let len = before.trk[0].trkseg[0].len() as u32; + let results = [ + splice(2, 1, &[], &[], &[]).apply(&mut fx.state()), + splice(0, 0, &[1.0], &[], &[]).apply(&mut fx.state()), + splice(0, len + 1, &[], &[], &[]).apply(&mut fx.state()), + ]; + assert!( + results + .iter() + .all(|r| matches!(r, Err(CommandError::InvalidData(_)))) + ); + assert_eq!( + splice(0, 0, &[], &[], &[]).apply(&mut fx.state()), + Err(CommandError::NothingToDo) + ); + assert!(std::rc::Rc::ptr_eq(&fx.files[&id], &before)); + } + + #[test] + fn test_splice_needs_a_segment_selection() { + let (mut fx, _) = loaded(); + fx.selection = Selection::Empty; + assert_eq!( + splice(0, 0, &[1.0], &[1.0], &[1.0]).apply(&mut fx.state()), + Err(CommandError::NothingToDo) + ); + } + + #[test] + fn test_splice_targets_last_segment_of_the_selection() { + let (mut fx, id) = loaded(); + let file = fx.files[&id].clone(); + assert!(file.trk.len() >= 2); + let last_trk = file.trk.len() - 1; + let last_seg = file.trk[last_trk].trkseg.len() - 1; + + // whole file selected: last segment of its last track + fx.selection = Selection::File { + file_ids: HashSet::from([id]), + }; + splice(0, 0, &[1.0], &[2.0], &[3.0]) + .apply(&mut fx.state()) + .unwrap(); + let after = &fx.files[&id]; + for (t, (b, a)) in file.trk.iter().zip(&after.trk).enumerate() { + for (s, (b, a)) in b.trkseg.iter().zip(&a.trkseg).enumerate() { + let target = (t, s) == (last_trk, last_seg); + assert_eq!(a.len(), b.len() + usize::from(target)); + assert_eq!(a.rev_id != b.rev_id, target); + } + } + + // several tracks selected: last selected one (in file order, whatever the set order) + let ids: Vec<_> = file.trk.iter().map(|t| t.id).collect(); + fx.selection = Selection::Track { + file_id: id, + trk_ids: HashSet::from([ids[1], ids[0]]), + }; + let len = |fx: &Fixture, t: usize| { + let trk = &fx.files[&id].trk[t]; + trk.trkseg.last().unwrap().len() + }; + let (l0, l1) = (len(&fx, 0), len(&fx, 1)); + splice(0, 0, &[1.0], &[2.0], &[3.0]) + .apply(&mut fx.state()) + .unwrap(); + assert_eq!(len(&fx, 0), l0); + assert_eq!(len(&fx, 1), l1 + 1); + + // several segments selected + let trk = &fx.files[&id].trk[0]; + let seg_ids: Vec<_> = trk.trkseg.iter().map(|s| s.id).collect(); + if seg_ids.len() >= 2 { + fx.selection = Selection::TrackSegment { + file_id: id, + trk_id: trk.id, + trkseg_ids: seg_ids.iter().copied().collect(), + }; + let lens: Vec<_> = trk.trkseg.iter().map(|s| s.len()).collect(); + splice(0, 0, &[1.0], &[2.0], &[3.0]) + .apply(&mut fx.state()) + .unwrap(); + let after: Vec<_> = fx.files[&id].trk[0] + .trkseg + .iter() + .map(|s| s.len()) + .collect(); + let n = lens.len(); + assert_eq!(after[..n - 1], lens[..n - 1]); + assert_eq!(after[n - 1], lens[n - 1] + 1); + } + } + + #[test] + fn test_splice_on_multiple_files_uses_last_in_file_order() { + let (mut fx, first) = loaded(); + let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap(); + Load { data: &data }.apply(&mut fx.state()).unwrap(); + let second = fx.order.0[1]; + fx.selection = Selection::File { + file_ids: HashSet::from([second, first]), + }; + let total = |fx: &Fixture, id: FileId| -> usize { + fx.files[&id] + .trk + .iter() + .flat_map(|t| &t.trkseg) + .map(|s| s.len()) + .sum() + }; + let (a, b) = (total(&fx, first), total(&fx, second)); + splice(0, 0, &[1.0], &[2.0], &[3.0]) + .apply(&mut fx.state()) + .unwrap(); + assert_eq!(total(&fx, first), a); + assert_eq!(total(&fx, second), b + 1); } }