This commit is contained in:
vcoppe committed 2026-10-14 09:22:07 +02:00
1 parent 41b9116c6a
commit 97d597e139
181 files changed
+1970 -2643

No files matched your search

@@ -0,0 +1,144 @@
use std::rc::Rc;
use crate::{Apply, CommandError, State, Trackpoint, replace_trackpoints};
use super::route::find_target;
/// Makes the trackpoint `index` of the selection (see `RoutingBuffer`) the start of its segment,
/// which is a loop: the trackpoints before it, and itself, are copied after the last trackpoint
/// of the segment, and the original ones are removed. The trackpoint is both the first and the
/// last one, which closes the loop on it.
///
/// The copies keep their durations, and start one second after the previous last trackpoint if
/// the segment has timestamps (see [`replace_trackpoints`]).
///
/// It is how the routing tool starts a loop somewhere else. The first and last trackpoints are
/// anchors shown at every zoom level, as any end of a segment.
#[derive(Debug)]
pub struct ChangeLoopStart {
pub index: u32,
}
impl Apply for ChangeLoopStart {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let index = self.index as usize;
let target = find_target(state, index, index)?.ok_or(CommandError::NothingToDo)?;
let mut file = (*state.files[&target.location.file_id]).clone();
let segment = &mut file.trk[target.location.trk].trkseg[target.location.seg];
let (start, len) = (target.start, segment.len());
if start == 0 || start >= len {
// it already is the start, or it is not a trackpoint
return Err(CommandError::NothingToDo);
}
// the trackpoint itself ends the loop as well
let moved: Vec<Trackpoint> = segment.iter().take(start + 1).cloned().collect();
// the copies are shifted after the last trackpoint, which is all that matters of the rest
let mut tail = vec![segment[len - 1].clone()];
replace_trackpoints(&mut tail, 1, 1, moved, None, None, false);
tail.remove(0);
// only the chunks at the ends of the segment change: the others stay shared
segment.splice(len, len, tail);
segment.splice(0, start, vec![]);
segment.rev_id = Default::default();
state.files.insert(target.location.file_id, Rc::new(file));
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{FileId, Load, Selection, TrackSegment, engine::command::fixture::Fixture};
use super::*;
fn loaded(path: &str) -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read(path).unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = fx.order.0[0];
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
(fx, id)
}
fn first_segment(fx: &Fixture, id: FileId) -> TrackSegment {
fx.files[&id].trk[0].trkseg[0].clone()
}
#[test]
fn test_the_points_before_move_to_the_end_and_the_loop_closes_on_the_start() {
let (mut fx, id) = loaded("data/simple.gpx");
let before = first_segment(&fx, id);
let n = before.len();
assert!(n > 10);
ChangeLoopStart { index: 4 }.apply(&mut fx.state()).unwrap();
let after = first_segment(&fx, id);
// the trackpoint is repeated at the end
assert_eq!(after.len(), n + 1);
assert_ne!(after.rev_id, before.rev_id);
let lng = |s: &TrackSegment, i: usize| s[i].coordinates.lng;
for i in 0..=n {
assert_eq!(lng(&after, i), lng(&before, (i + 4) % n), "{i}");
}
assert_eq!(lng(&after, n), lng(&after, 0));
// the start and the end of the new segment are anchors, whatever they were
assert_eq!(after[0].anchor, Some(0));
assert_eq!(after[n].anchor, Some(0));
}
#[test]
fn test_the_moved_part_is_shifted_after_the_last_point() {
let (mut fx, id) = loaded("data/with_time.gpx");
let before = first_segment(&fx, id);
let n = before.len();
ChangeLoopStart { index: 30 }
.apply(&mut fx.state())
.unwrap();
let after = first_segment(&fx, id);
assert_eq!(after.len(), n + 1);
let times: Vec<_> = after.iter().map(|p| p.time.unwrap()).collect();
assert!(times.windows(2).all(|w| w[0] < w[1]), "{times:?}");
// the part that was after the index is untouched
for i in 0..n - 30 {
assert_eq!(after[i].time, before[30 + i].time);
}
// the copies follow the last point by a second, and keep their durations
let last = before[n - 1].time.unwrap();
assert_eq!(after[n - 30].time, Some(last + 1000));
for i in n - 30..n {
let original = i - (n - 30);
assert_eq!(
after[i + 1].time.unwrap() - after[i].time.unwrap(),
before[original + 1].time.unwrap() - before[original].time.unwrap(),
"{i}"
);
}
}
#[test]
fn test_nothing_to_do_on_the_start_or_out_of_the_trackpoints() {
let (mut fx, id) = loaded("data/simple.gpx");
let before = fx.files[&id].clone();
assert_eq!(
ChangeLoopStart { index: 0 }.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
let total = first_segment(&fx, id).len() as u32;
assert!(matches!(
ChangeLoopStart { index: total + 5 }.apply(&mut fx.state()),
Err(CommandError::InvalidData(_))
));
assert!(Rc::ptr_eq(&fx.files[&id], &before));
}
}
+403
View File
@@ -0,0 +1,403 @@
use std::{collections::HashSet, rc::Rc};
use crate::{
Apply, CommandError, Edit, Editor, File, FileId, LngLat, LngLatBounds, Selection, State,
TrackSegment, TrackSegmentId, Trackpoint, Waypoint, delete_waypoints, update_selected,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum CleanType {
Inside,
Outside,
}
/// Removes the trackpoints and/or waypoints of the selection lying inside (or outside) a
/// rectangle. A selected file cleans its tracks and its waypoints, a selected track or segment
/// only trackpoints, selected waypoints only waypoints.
#[derive(Debug)]
pub struct Clean {
pub bounds: LngLatBounds,
pub type_: CleanType,
pub trkpt: bool,
pub wpt: bool,
}
impl Clean {
fn contains(&self, c: LngLat) -> bool {
(self.bounds.sw.lng..=self.bounds.ne.lng).contains(&c.lng)
&& (self.bounds.sw.lat..=self.bounds.ne.lat).contains(&c.lat)
}
fn removes(&self, c: LngLat) -> bool {
self.contains(c) == (self.type_ == CleanType::Inside)
}
}
impl Apply for Clean {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
match &*state.selection {
Selection::Waypoint { file_id, .. } | Selection::Waypoints { file_id } => {
let file_id = *file_id;
if !self.wpt {
return Err(CommandError::NothingToDo);
}
let wpt_ids = match &*state.selection {
Selection::Waypoint { wpt_ids, .. } => Some(wpt_ids.clone()),
_ => None,
};
let selected =
|wpt: &Waypoint| wpt_ids.as_ref().is_none_or(|ids| ids.contains(&wpt.id));
*state.selection = delete_waypoints(state.files, file_id, |wpt| {
selected(wpt) && self.removes(wpt.coordinates)
})?;
Ok(())
}
Selection::Empty => Err(CommandError::NothingToDo),
_ => {
let mut cleaner = Cleaner {
clean: &self,
changed: false,
emptied: HashSet::new(),
wpt_files: HashSet::new(),
};
update_selected(state, &mut cleaner);
if cleaner.changed {
prune(state, &cleaner.emptied, &cleaner.wpt_files);
Ok(())
} else {
Err(CommandError::NothingToDo)
}
}
}
}
}
struct Cleaner<'a> {
clean: &'a Clean,
changed: bool,
/// Segments left without trackpoints by the clean.
emptied: HashSet<TrackSegmentId>,
/// Files that lost waypoints.
wpt_files: HashSet<FileId>,
}
impl Editor for Cleaner<'_> {
fn file(&mut self, file: &mut File) -> Edit {
let mut edit = Edit::Unchanged;
for trk in &mut file.trk {
if self.track(trk) == Edit::Changed {
edit = Edit::Changed;
}
}
if self.clean.wpt
&& file.wpt.edit(
|wpt| self.clean.removes(wpt.coordinates),
|wpts| {
wpts.retain(|wpt| !self.clean.removes(wpt.coordinates));
true
},
)
{
edit = Edit::Changed;
self.wpt_files.insert(file.id);
}
self.changed |= edit == Edit::Changed;
edit
}
fn segment(&mut self, segment: &mut TrackSegment) -> Edit {
if !self.clean.trkpt || !segment.iter().any(|pt| self.clean.removes(pt.coordinates)) {
return Edit::Unchanged;
}
let kept: Vec<Trackpoint> = segment
.iter()
.filter(|pt| !self.clean.removes(pt.coordinates))
.cloned()
.collect();
if kept.is_empty() {
self.emptied.insert(segment.id);
}
segment.splice(0, segment.len(), kept);
self.changed = true;
Edit::Changed
}
}
/// Removes what the clean left empty: the emptied segments, the tracks that lost all their
/// segments and the files that lost something and have nothing left. The selection falls back
/// to the closest element still existing.
fn prune(state: &mut State, emptied: &HashSet<TrackSegmentId>, wpt_files: &HashSet<FileId>) {
let ids: Vec<FileId> = state
.files
.iter()
.filter(|(id, file)| {
wpt_files.contains(id)
|| file
.trk
.iter()
.any(|trk| trk.trkseg.iter().any(|seg| emptied.contains(&seg.id)))
})
.map(|(id, _)| *id)
.collect();
for id in ids {
let mut file = (*state.files[&id]).clone();
let lost_something = !emptied.is_empty() || wpt_files.contains(&id);
file.trk.retain_mut(|trk| {
let len = trk.trkseg.len();
trk.trkseg.retain(|seg| !emptied.contains(&seg.id));
trk.trkseg.len() == len || !trk.trkseg.is_empty()
});
if lost_something && file.trk.is_empty() && file.wpt.is_empty() {
state.files.remove(&id);
state.order.0.retain(|f| *f != id);
} else {
state.files.insert(id, Rc::new(file));
}
}
*state.selection = fixed_selection(state);
}
fn fixed_selection(state: &State) -> Selection {
let files = &*state.files;
match &*state.selection {
Selection::File { file_ids } => {
let file_ids: HashSet<_> = file_ids
.iter()
.filter(|id| files.contains_key(id))
.copied()
.collect();
if file_ids.is_empty() {
Selection::Empty
} else {
Selection::File { file_ids }
}
}
Selection::Track { file_id, trk_ids } => {
let Some(file) = files.get(file_id) else {
return Selection::Empty;
};
let trk_ids: HashSet<_> = file
.trk
.iter()
.map(|trk| trk.id)
.filter(|id| trk_ids.contains(id))
.collect();
if trk_ids.is_empty() {
Selection::File {
file_ids: [*file_id].into(),
}
} else {
Selection::Track {
file_id: *file_id,
trk_ids,
}
}
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
let Some(file) = files.get(file_id) else {
return Selection::Empty;
};
let Some(trk) = file.trk.iter().find(|trk| trk.id == *trk_id) else {
return Selection::File {
file_ids: [*file_id].into(),
};
};
let trkseg_ids: HashSet<_> = trk
.trkseg
.iter()
.map(|seg| seg.id)
.filter(|id| trkseg_ids.contains(id))
.collect();
if trkseg_ids.is_empty() {
Selection::Track {
file_id: *file_id,
trk_ids: [*trk_id].into(),
}
} else {
Selection::TrackSegment {
file_id: *file_id,
trk_id: *trk_id,
trkseg_ids,
}
}
}
Selection::Waypoints { file_id } | Selection::Waypoint { file_id, .. } => {
if files.contains_key(file_id) {
Selection::File {
file_ids: [*file_id].into(),
}
} else {
Selection::Empty
}
}
Selection::Empty => Selection::Empty,
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{FileId, Load, WaypointChunk, Waypoints, 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,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = fx.order.0[0];
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
(fx, id)
}
fn bounds(w: f64, s: f64, e: f64, n: f64) -> LngLatBounds {
LngLatBounds {
sw: LngLat { lng: w, lat: s },
ne: LngLat { lng: e, lat: n },
}
}
fn clean(bounds: LngLatBounds, type_: CleanType) -> Clean {
Clean {
bounds,
type_,
trkpt: true,
wpt: true,
}
}
fn count(fx: &Fixture, id: FileId) -> usize {
fx.files
.get(&id)
.into_iter()
.flat_map(|file| &file.trk)
.flat_map(|trk| &trk.trkseg)
.map(|seg| seg.len())
.sum()
}
#[test]
fn test_inside_everything_removes_all_trackpoints() {
let (mut fx, id) = loaded();
assert!(count(&fx, id) > 0);
clean(bounds(-180.0, -90.0, 180.0, 90.0), CleanType::Inside)
.apply(&mut fx.state())
.unwrap();
assert_eq!(count(&fx, id), 0);
// nothing is left of the file
assert!(!fx.files.contains_key(&id));
assert!(fx.order.0.is_empty());
assert_eq!(fx.selection, Selection::Empty);
}
#[test]
fn test_emptied_segments_and_tracks_are_removed() {
let (mut fx, id) = loaded();
let before = fx.files[&id].clone();
let seg = &before.trk[0].trkseg[0];
let pts: Vec<_> = seg.iter().map(|p| p.coordinates).collect();
let mut b = LngLatBounds::default();
pts.iter().for_each(|c| b.extend(*c));
clean(b, CleanType::Inside).apply(&mut fx.state()).unwrap();
let after = &fx.files[&id];
assert!(after.trk.iter().all(|t| !t.trkseg.is_empty()));
assert!(
after
.trk
.iter()
.flat_map(|t| &t.trkseg)
.all(|s| !s.is_empty())
);
}
#[test]
fn test_outside_everything_changes_nothing() {
let (mut fx, id) = loaded();
let before = fx.files[&id].clone();
let result =
clean(bounds(-180.0, -90.0, 180.0, 90.0), CleanType::Outside).apply(&mut fx.state());
assert!(matches!(result, Err(CommandError::NothingToDo)));
// the file was not even copied
assert!(std::rc::Rc::ptr_eq(&fx.files[&id], &before));
}
#[test]
fn test_partial_clean_only_removes_points_inside_and_bumps_revision() {
let (mut fx, id) = loaded();
let before = fx.files[&id].clone();
let first = before.trk[0].trkseg[0].iter().next().unwrap().coordinates;
let total = count(&fx, id);
// tiny rectangle around the first point
let b = bounds(
first.lng - 1e-9,
first.lat - 1e-9,
first.lng + 1e-9,
first.lat + 1e-9,
);
clean(b, CleanType::Inside).apply(&mut fx.state()).unwrap();
let removed = total - count(&fx, id);
assert!(removed >= 1);
let seg = &fx.files[&id].trk[0].trkseg[0];
assert_ne!(seg.rev_id, before.trk[0].trkseg[0].rev_id);
assert!(seg.iter().all(|pt| !b_contains(&b, pt.coordinates)));
}
fn b_contains(b: &LngLatBounds, c: LngLat) -> bool {
c.lng >= b.sw.lng && c.lng <= b.ne.lng && c.lat >= b.sw.lat && c.lat <= b.ne.lat
}
#[test]
fn test_waypoints_flag_and_selection() {
let (mut fx, id) = loaded();
let mut file = (*fx.files[&id]).clone();
file.wpt = Waypoints::new([WaypointChunk {
wpt: vec![Waypoint::default(), Waypoint::default()],
..Default::default()
}]);
let ids: Vec<_> = file.wpt.iter().map(|w| w.id).collect();
fx.files.insert(id, std::rc::Rc::new(file));
let everywhere = bounds(-180.0, -90.0, 180.0, 90.0);
let wpt_count = |fx: &Fixture| fx.files.get(&id).map_or(0, |f| f.wpt.len());
// trackpoints only: waypoints are kept
Clean {
wpt: false,
..clean(everywhere, CleanType::Inside)
}
.apply(&mut fx.state())
.unwrap();
assert_eq!(wpt_count(&fx), 2);
// one selected waypoint
fx.selection = Selection::Waypoint {
file_id: id,
wpt_ids: HashSet::from([ids[0]]),
};
clean(everywhere, CleanType::Inside)
.apply(&mut fx.state())
.unwrap();
assert_eq!(wpt_count(&fx), 1);
// whole file
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
clean(everywhere, CleanType::Inside)
.apply(&mut fx.state())
.unwrap();
assert_eq!(wpt_count(&fx), 0);
}
}
+335
View File
@@ -0,0 +1,335 @@
use std::{
collections::{HashMap, HashSet},
rc::Rc,
};
use crate::{
Apply, CommandError, File, FileId, SegmentLocation, Selection, State, TrackSegment,
TrackSegmentId,
};
/// Keeps the trackpoints `start` to `end` (both included) of the selection, which are counted
/// over its segments one after the other (as in the `RoutingBuffer`), and removes the others.
///
/// The segments that have no trackpoint left are removed. So are the tracks that have no segment
/// left, if tracks or files are selected (not if only segments are), and the files that have no
/// trackpoint left, if files are selected. A range that goes past the last trackpoint stops at it.
#[derive(Debug)]
pub struct Crop {
pub start: u32,
pub end: u32,
}
/// What happens to a selected segment.
struct Plan {
location: SegmentLocation,
/// The trackpoints that are kept, `None` if the segment goes away.
keep: Option<(usize, usize)>,
len: usize,
}
impl Apply for Crop {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let (start, end) = (self.start as usize, self.end as usize);
if start > end {
return Err(CommandError::InvalidData(
"invalid trackpoints range".into(),
));
}
let mut offset = 0;
let plans: Vec<Plan> = state
.selection
.segment_locations(state.files, &state.order.0)
.into_iter()
.map(|location| {
let len =
state.files[&location.file_id].trk[location.trk].trkseg[location.seg].len();
let keep = (len > 0 && start < offset + len && end >= offset)
.then(|| (start.saturating_sub(offset), (end - offset).min(len - 1)));
offset += len;
Plan {
location,
keep,
len,
}
})
.collect();
if plans
.iter()
.all(|plan| plan.keep == Some((0, plan.len.saturating_sub(1))))
{
// everything is kept as it is, or there is nothing
return Err(CommandError::NothingToDo);
}
// the files and tracks to prune, the segments to remove
let (prune_tracks, prune_files) = match &*state.selection {
Selection::File { .. } => (true, true),
Selection::Track { .. } => (true, false),
_ => (false, false),
};
let mut files: HashMap<FileId, File> = HashMap::new();
let mut removed: HashSet<TrackSegmentId> = HashSet::new();
let mut had_points: HashSet<FileId> = HashSet::new();
for plan in &plans {
let SegmentLocation { file_id, trk, seg } = plan.location;
let file = files
.entry(file_id)
.or_insert_with(|| (*state.files[&file_id]).clone());
if plan.len > 0 {
had_points.insert(file_id);
}
let segment = &mut file.trk[trk].trkseg[seg];
match plan.keep {
None => {
removed.insert(segment.id);
}
Some((from, to)) => crop_segment(segment, from, to),
}
}
let mut gone: Vec<FileId> = vec![];
for (file_id, file) in files.iter_mut() {
// the tracks that lost segments
let touched: Vec<bool> = file
.trk
.iter_mut()
.map(|track| {
let before = track.trkseg.len();
track
.trkseg
.retain(|segment| !removed.contains(&segment.id));
before != track.trkseg.len()
})
.collect();
if prune_tracks {
let mut touched = touched.into_iter();
file.trk
.retain(|track| !(touched.next().unwrap() && track.trkseg.is_empty()));
}
if prune_files && file.trk.is_empty() && had_points.contains(file_id) {
gone.push(*file_id);
}
}
for (file_id, file) in files {
if gone.contains(&file_id) {
state.files.remove(&file_id);
state.order.0.retain(|id| *id != file_id);
} else {
state.files.insert(file_id, Rc::new(file));
}
}
Ok(())
}
}
/// Keeps the trackpoints `from` to `to` of a segment, which are in it. Only the chunks at the ends
/// are changed.
fn crop_segment(segment: &mut TrackSegment, from: usize, to: usize) {
let len = segment.len();
if from == 0 && to + 1 >= len {
return;
}
segment.splice(to + 1, len, vec![]);
segment.splice(0, from, vec![]);
segment.rev_id = Default::default();
}
#[cfg(test)]
mod tests {
use crate::{Load, engine::command::fixture::Fixture};
use super::*;
fn loaded(path: &str) -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read(path).unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = fx.order.0[0];
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
(fx, id)
}
fn crop(start: u32, end: u32) -> Crop {
Crop { start, end }
}
/// The longitudes of the trackpoints of a file, one list per segment.
fn paths(file: &File) -> Vec<Vec<f64>> {
file.trk
.iter()
.flat_map(|track| &track.trkseg)
.map(|segment| segment.iter().map(|p| p.coordinates.lng).collect())
.collect()
}
#[test]
fn test_crop_a_file_keeps_the_range_over_its_segments() {
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
let before = paths(&fx.files[&id]);
let flat: Vec<f64> = before.iter().flatten().copied().collect();
let (start, end) = (2, flat.len() - 3);
crop(start as u32, end as u32)
.apply(&mut fx.state())
.unwrap();
let after: Vec<f64> = paths(&fx.files[&id]).iter().flatten().copied().collect();
assert_eq!(after, flat[start..=end]);
// the ends of the segments that are left are anchors
for track in &fx.files[&id].trk {
for segment in &track.trkseg {
assert!(!segment.is_empty());
assert_eq!(segment[0].anchor, Some(0));
assert_eq!(segment[segment.len() - 1].anchor, Some(0));
}
}
}
#[test]
fn test_what_is_outside_goes_away() {
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
let file = fx.files[&id].clone();
let lens: Vec<Vec<usize>> = file
.trk
.iter()
.map(|t| t.trkseg.iter().map(|segment| segment.len()).collect())
.collect();
assert!(lens.len() >= 2);
// from the start of the last track to the end
let before_last: usize = lens[..lens.len() - 1].iter().flatten().sum();
crop(before_last as u32, u32::MAX)
.apply(&mut fx.state())
.unwrap();
let after = &fx.files[&id];
// the other tracks are gone, the last one is whole
assert_eq!(after.trk.len(), 1);
assert_eq!(after.trk[0].id, file.trk.last().unwrap().id);
assert_eq!(
after.trk[0]
.trkseg
.iter()
.map(|segment| segment.len())
.collect::<Vec<_>>(),
*lens.last().unwrap()
);
// the selection followed
assert!(matches!(&fx.selection, Selection::File { .. }));
}
#[test]
fn test_crop_inside_a_segment_changes_it_only() {
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
let file = fx.files[&id].clone();
let trk = &file.trk[0];
assert!(trk.trkseg.len() >= 2);
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id: trk.id,
trkseg_ids: HashSet::from([trk.trkseg[1].id]),
};
let len = trk.trkseg[1].len();
assert!(len > 4);
crop(1, len as u32 - 2).apply(&mut fx.state()).unwrap();
let after = &fx.files[&id].trk[0];
assert_eq!(after.trkseg[1].len(), len - 2);
assert_ne!(after.trkseg[1].rev_id, trk.trkseg[1].rev_id);
// the other segments are not in the selection, whatever the range
assert_eq!(after.trkseg[0].rev_id, trk.trkseg[0].rev_id);
assert_eq!(fx.files[&id].trk.len(), file.trk.len());
}
#[test]
fn test_segments_out_of_the_range_are_removed_but_not_their_track() {
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
let trk = fx.files[&id].trk[0].clone();
assert!(trk.trkseg.len() >= 2);
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id: trk.id,
trkseg_ids: trk.trkseg.iter().map(|s| s.id).collect(),
};
// only the first segment, entirely
let first = trk.trkseg[0].len();
crop(0, first as u32 - 1).apply(&mut fx.state()).unwrap();
let after = &fx.files[&id].trk[0];
assert_eq!(after.trkseg.len(), 1);
assert_eq!(after.trkseg[0].id, trk.trkseg[0].id);
// nothing at all: the segments go, the track stays
crop(100_000, 100_001).apply(&mut fx.state()).unwrap();
assert!(fx.files[&id].trk[0].trkseg.is_empty());
}
#[test]
fn test_files_out_of_the_range_are_deleted() {
let (mut fx, first) = loaded("data/simple.gpx");
let data = std::fs::read("data/with_time.gpx").unwrap();
Load {
data: &data,
name: "second",
}
.apply(&mut fx.state())
.unwrap();
let second = fx.order.0[1];
fx.selection = Selection::File {
file_ids: HashSet::from([first, second]),
};
let first_len: usize = fx.files[&first]
.trk
.iter()
.flat_map(|t| &t.trkseg)
.map(|segment| segment.len())
.sum();
crop(first_len as u32 + 1, u32::MAX)
.apply(&mut fx.state())
.unwrap();
assert!(!fx.files.contains_key(&first));
assert_eq!(fx.order.0, [second]);
let kept: usize = fx.files[&second]
.trk
.iter()
.flat_map(|t| &t.trkseg)
.map(|segment| segment.len())
.sum();
assert!(kept > 0);
}
#[test]
fn test_nothing_to_do_or_invalid() {
let (mut fx, id) = loaded("data/simple.gpx");
let before = fx.files[&id].clone();
// the whole selection
assert_eq!(
crop(0, u32::MAX).apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
assert!(matches!(
crop(5, 2).apply(&mut fx.state()),
Err(CommandError::InvalidData(_))
));
fx.selection = Selection::Empty;
assert_eq!(
crop(0, 3).apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
fx.selection = Selection::Waypoints { file_id: id };
assert_eq!(
crop(0, 3).apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
assert!(Rc::ptr_eq(&fx.files[&id], &before));
}
}
@@ -0,0 +1,162 @@
use crate::{Apply, CommandError, FileId, State, WaypointId, set_waypoint_fields, update_waypoint};
/// Changes a waypoint of a file, whatever is selected (what the form of the waypoint tool does):
/// its name, description, icon, link, position and elevation. The strings that are empty remove
/// the field.
#[derive(Debug)]
pub struct EditWaypoint<'a> {
pub file_id: FileId,
pub waypoint_id: WaypointId,
pub lng: f64,
pub lat: f64,
pub ele: f64,
pub name: &'a str,
pub desc: &'a str,
pub icon: &'a str,
pub link: &'a str,
}
impl Apply for EditWaypoint<'_> {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
update_waypoint(state, self.file_id, self.waypoint_id, |wpt| {
set_waypoint_fields(
wpt,
(self.lng, self.lat, self.ele),
self.name,
self.desc,
self.icon,
self.link,
)
})
}
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use crate::{
File, Link, Waypoint, WaypointChunk, engine::command::fixture::Fixture, waypoint_ids,
};
use super::*;
#[test]
fn test_edit_the_given_waypoint() {
let mut fx = Fixture::default();
let mut file = File::default();
file.wpt.push(WaypointChunk {
wpt: vec![
Waypoint {
name: Some("old".into()),
desc: Some("old description".into()),
cmt: Some("old comment".into()),
sym: Some("Flag".into()),
links: vec![
Link {
href: "https://old".into(),
text: None,
},
Link {
href: "https://second".into(),
text: Some("second".into()),
},
],
..Default::default()
},
Waypoint {
name: Some("other".into()),
..Default::default()
},
],
..Default::default()
});
let id = file.id;
let ids: Vec<_> = waypoint_ids(&file).collect();
fx.files.insert(id, Rc::new(file));
EditWaypoint {
file_id: id,
waypoint_id: ids[0],
lng: 4.5,
lat: 50.5,
ele: 12.0,
name: "new",
desc: "new description",
icon: "Summit",
link: "",
}
.apply(&mut fx.state())
.unwrap();
let wpts: Vec<_> = fx.files[&id].wpt.iter().collect();
let edited = wpts[0];
assert_eq!(edited.id, ids[0]);
assert_eq!(edited.name.as_deref(), Some("new"));
assert_eq!(edited.desc.as_deref(), Some("new description"));
assert_eq!(edited.cmt.as_deref(), Some("new description"));
assert_eq!(edited.sym.as_deref(), Some("Summit"));
// an empty link removes the first one, the others are not the form's business
assert_eq!(edited.links.len(), 1);
assert_eq!(edited.links[0].href, "https://second");
assert_eq!(
(edited.coordinates.lng, edited.coordinates.lat, edited.ele),
(4.5, 50.5, 12.0)
);
// the other waypoint is as it was
assert_eq!(wpts[1].name.as_deref(), Some("other"));
}
#[test]
fn test_the_link_of_the_form_is_the_first_one() {
let mut fx = Fixture::default();
let mut file = File::default();
file.wpt.push(WaypointChunk {
wpt: vec![Waypoint {
links: vec![
Link {
href: "https://first".into(),
text: Some("first".into()),
},
Link {
href: "https://second".into(),
text: None,
},
],
..Default::default()
}],
..Default::default()
});
let (id, wpt_id) = (file.id, waypoint_ids(&file).next().unwrap());
fx.files.insert(id, Rc::new(file));
let edit = |fx: &mut Fixture, link: &str| {
EditWaypoint {
file_id: id,
waypoint_id: wpt_id,
lng: 0.0,
lat: 0.0,
ele: 0.0,
name: "",
desc: "",
icon: "",
link,
}
.apply(&mut fx.state())
.unwrap();
fx.files[&id].wpt.iter().next().unwrap().links.clone()
};
// the same link keeps its text
let links = edit(&mut fx, "https://first");
assert_eq!(links.len(), 2);
assert_eq!(links[0].text.as_deref(), Some("first"));
// another one replaces the first, and the text that described the old one
let links = edit(&mut fx, "https://other");
assert_eq!(links.len(), 2);
assert_eq!(
(links[0].href.as_str(), links[0].text.clone()),
("https://other", None)
);
assert_eq!(links[1].href, "https://second");
}
}
@@ -0,0 +1,133 @@
use crate::{Apply, CommandError, State, update_segments};
/// Sets the elevation of the trackpoints of the selected segments (the ones of the statistics of
/// the selection, in the same order): `ele` has one elevation per trackpoint.
#[derive(Debug)]
pub struct Elevation<'a> {
pub ele: &'a [f64],
}
impl Apply for Elevation<'_> {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let locations = state
.selection
.segment_locations(state.files, &state.order.0);
let count: usize = locations
.iter()
.map(|location| {
state.files[&location.file_id].trk[location.trk].trkseg[location.seg].len()
})
.sum();
if count == 0 {
return Err(CommandError::NothingToDo);
}
if count != self.ele.len() {
return Err(CommandError::InvalidData(format!(
"{} elevations for {count} trackpoints",
self.ele.len()
)));
}
let mut ele = self.ele.iter().copied();
update_segments(state, &locations, |_, segment| {
segment.update_all(|_, pt| pt.ele = ele.next().unwrap_or(pt.ele));
});
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{FileId, Load, Selection, engine::command::fixture::Fixture};
use super::*;
fn loaded(path: &str) -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read(path).unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = fx.order.0[0];
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
(fx, id)
}
fn trackpoint_elevations(fx: &Fixture, id: FileId) -> Vec<f64> {
fx.files[&id]
.trk
.iter()
.flat_map(|trk| {
trk.trkseg
.iter()
.flat_map(|seg| seg.iter().map(|pt| pt.ele))
})
.collect()
}
#[test]
fn test_a_file_gets_elevation_on_all_its_trackpoints() {
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
let count = trackpoint_elevations(&fx, id).len();
let ele: Vec<f64> = (0..count).map(|i| 100.0 + i as f64).collect();
Elevation { ele: &ele }.apply(&mut fx.state()).unwrap();
assert_eq!(trackpoint_elevations(&fx, id), ele);
}
#[test]
fn test_segments_only_touch_their_trackpoints() {
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
let before = trackpoint_elevations(&fx, id);
let track = &fx.files[&id].trk[0];
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id: track.id,
trkseg_ids: HashSet::from([track.trkseg[0].id]),
};
let rev_ids: Vec<_> = track.trkseg.iter().map(|seg| seg.rev_id).collect();
let len = track.trkseg[0].len();
Elevation {
ele: &vec![5.0; len],
}
.apply(&mut fx.state())
.unwrap();
let after = trackpoint_elevations(&fx, id);
assert_eq!(after[..len], vec![5.0; len]);
assert_eq!(after[len..], before[len..]);
let segments = &fx.files[&id].trk[0].trkseg;
assert_ne!(segments[0].rev_id, rev_ids[0]);
assert_eq!(segments[1].rev_id, rev_ids[1]);
}
#[test]
fn test_wrong_number_of_elevations() {
let (mut fx, id) = loaded("data/simple.gpx");
let before = trackpoint_elevations(&fx, id);
assert!(matches!(
Elevation { ele: &[1.0] }.apply(&mut fx.state()),
Err(CommandError::InvalidData(_))
));
assert_eq!(trackpoint_elevations(&fx, id), before);
}
#[test]
fn test_nothing_selected() {
let (mut fx, _) = loaded("data/simple.gpx");
fx.selection = Selection::Empty;
assert!(matches!(
Elevation { ele: &[] }.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
));
}
}
+570
View File
@@ -0,0 +1,570 @@
use std::{collections::HashSet, rc::Rc};
use crate::{
Apply, CommandError, File, FileId, LngLat, LngLatBounds, Selection, State, Track, TrackId,
TrackSegment, Waypoint, Waypoints, copy_waypoint, crossarc_lnglat, distance,
};
/// Splits the selected elements that are made of several segments into elements that have one
/// each:
///
/// - files: a file with several tracks becomes one file per track, in which the track is split
/// into one track per segment. A file with a single track becomes one file per segment. The new
/// files are in the place of the file, which goes, and are selected. They are named after the
/// track, or the file, and numbered. The waypoints go to the files that have the segments
/// closest to them (to the first file if the file has no trackpoint).
/// - tracks: each selected track is replaced by one track per segment, which are selected. They
/// are named after the track and numbered.
///
/// What has a single segment is left as it is. There is nothing to do if nothing was changed.
#[derive(Debug)]
pub struct Extract;
impl Apply for Extract {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let selection = state.selection.clone();
match &selection {
Selection::File { file_ids } => extract_files(state, file_ids),
Selection::Track { file_id, trk_ids } => extract_tracks(state, *file_id, trk_ids),
_ => Err(CommandError::NothingToDo),
}
}
}
fn extract_files(state: &mut State, file_ids: &HashSet<FileId>) -> Result<(), CommandError> {
// the files that have something to extract, with the files that come out of them
let mut extracted: Vec<(FileId, Vec<File>)> = vec![];
for id in state.order.0.iter().filter(|id| file_ids.contains(id)) {
let file = &state.files[id];
let segments: usize = file.trk.iter().map(|track| track.trkseg.len()).sum();
if segments > 1 {
extracted.push((*id, extract_file(file)));
}
}
if extracted.is_empty() {
return Err(CommandError::NothingToDo);
}
let mut order = vec![];
for id in state.order.0.iter() {
match extracted.iter().find(|(original, _)| original == id) {
Some((_, files)) => order.extend(files.iter().map(|file| file.id)),
None => order.push(*id),
}
}
let mut selected: HashSet<FileId> = file_ids.clone();
for (original, files) in extracted {
state.files.remove(&original);
selected.remove(&original);
for file in files {
selected.insert(file.id);
state.files.insert(file.id, Rc::new(file));
}
}
state.order.0 = order;
*state.selection = Selection::File { file_ids: selected };
Ok(())
}
/// The files that a file with several segments becomes.
fn extract_file(file: &File) -> Vec<File> {
// for each new file: its tracks and its name, and the segments that go with it
struct Part {
tracks: Vec<Track>,
name: String,
}
let (parts, owner): (Vec<Part>, Vec<usize>) = if file.trk.len() > 1 {
// one file per track, the segments of a track follow it
let parts = file
.trk
.iter()
.enumerate()
.map(|(i, track)| Part {
tracks: split_track(track),
name: track
.info
.name
.clone()
.unwrap_or_else(|| format!("{} ({})", file.info.name, i + 1)),
})
.collect();
let owner = file
.trk
.iter()
.enumerate()
.flat_map(|(i, track)| track.trkseg.iter().map(move |_| i))
.collect();
(parts, owner)
} else {
// one file per segment
let track = &file.trk[0];
let base = track
.info
.name
.clone()
.unwrap_or_else(|| file.info.name.clone());
let parts = track
.trkseg
.iter()
.enumerate()
.map(|(i, segment)| Part {
tracks: vec![Track {
id: if i == 0 { track.id } else { Default::default() },
trkseg: vec![segment.clone()],
..track.clone()
}],
name: format!("{base} ({})", i + 1),
})
.collect();
let owner = (0..track.trkseg.len()).collect();
(parts, owner)
};
// the waypoints go with the segments that are the closest to them
let segments: Vec<&TrackSegment> = file.trk.iter().flat_map(|track| &track.trkseg).collect();
let mut waypoints: Vec<Vec<Waypoint>> = vec![vec![]; parts.len()];
for waypoint in file.wpt.iter() {
let mut owners: Vec<usize> = closest_segments(&segments, waypoint.coordinates)
.into_iter()
.map(|segment| owner[segment])
.collect();
owners.sort_unstable();
owners.dedup();
if owners.is_empty() {
owners.push(0);
}
for (n, part) in owners.into_iter().enumerate() {
// a waypoint that goes to several files has a new id in all but the first
waypoints[part].push(if n == 0 {
waypoint.clone()
} else {
copy_waypoint(waypoint)
});
}
}
parts
.into_iter()
.zip(waypoints)
.map(|(part, waypoints)| {
let mut extracted = File {
id: Default::default(),
trk: part.tracks,
wpt: Waypoints::default(),
..file.clone()
};
extracted.info.name = part.name;
extracted.wpt.insert_at(0, waypoints);
extracted
})
.collect()
}
fn extract_tracks(
state: &mut State,
file_id: FileId,
trk_ids: &HashSet<TrackId>,
) -> Result<(), CommandError> {
let file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?;
let mut changed = false;
let mut tracks = vec![];
let mut selected: HashSet<TrackId> = trk_ids.clone();
for track in &file.trk {
if trk_ids.contains(&track.id) && track.trkseg.len() > 1 {
changed = true;
selected.remove(&track.id);
let pieces = split_track(track);
selected.extend(pieces.iter().map(|piece| piece.id));
tracks.extend(pieces);
} else {
tracks.push(track.clone());
}
}
if !changed {
return Err(CommandError::NothingToDo);
}
let mut file = (**file).clone();
file.trk = tracks;
state.files.insert(file_id, Rc::new(file));
*state.selection = Selection::Track {
file_id,
trk_ids: selected,
};
Ok(())
}
/// The tracks that a track becomes: one per segment, the first one with the id of the track. The
/// others have the same info, with the name of the track and the number of the segment, if it has
/// a name. A track with at most one segment stays as it is.
fn split_track(track: &Track) -> Vec<Track> {
if track.trkseg.len() <= 1 {
return vec![track.clone()];
}
track
.trkseg
.iter()
.enumerate()
.map(|(i, segment)| {
let mut piece = Track {
id: if i == 0 { track.id } else { Default::default() },
trkseg: vec![segment.clone()],
..track.clone()
};
piece.info.name = track
.info
.name
.as_ref()
.map(|name| format!("{name} ({})", i + 1));
piece
})
.collect()
}
/// The indices of the segments that have the trackpoint that is the closest to `point`, which are
/// several when the closest trackpoints are as close.
///
/// The segments are looked at from the closest to the farthest bounds, which is how they are
/// left out: the ones whose bounds are farther than the closest trackpoint found.
fn closest_segments(segments: &[&TrackSegment], point: LngLat) -> Vec<usize> {
let mut by_bounds: Vec<(f64, usize)> = segments
.iter()
.enumerate()
.filter(|(_, segment)| !segment.is_empty())
.map(|(i, segment)| {
let mut bounds = LngLatBounds::default();
segment
.iter()
.for_each(|trkpt| bounds.extend(trkpt.coordinates));
(distance_to_bounds(&bounds, point), i)
})
.collect();
by_bounds.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut closest = f64::MAX;
let mut indices: Vec<usize> = vec![];
for (bounds_distance, i) in by_bounds {
if bounds_distance > closest {
break;
}
for trkpt in segments[i].iter() {
// in meters, like the distances to the bounds
let d = distance(trkpt.coordinates, point) * 1000.0;
if d < closest {
closest = d;
indices = vec![i];
} else if d == closest {
indices.push(i);
}
}
}
indices.sort_unstable();
indices.dedup();
indices
}
/// 0 inside the bounds, else the distance in meters to the closest side.
fn distance_to_bounds(bounds: &LngLatBounds, point: LngLat) -> f64 {
let (sw, ne) = (bounds.sw, bounds.ne);
if (sw.lng..=ne.lng).contains(&point.lng) && (sw.lat..=ne.lat).contains(&point.lat) {
return 0.0;
}
let nw = LngLat {
lng: sw.lng,
lat: ne.lat,
};
let se = LngLat {
lng: ne.lng,
lat: sw.lat,
};
[(nw, ne), (ne, se), (se, sw), (sw, nw)]
.into_iter()
.map(|(a, b)| crossarc_lnglat(a, b, point))
.fold(f64::MAX, f64::min)
}
#[cfg(test)]
mod tests {
use crate::{TrackInfo, Trackpoint, engine::command::fixture::Fixture};
use super::*;
/// A segment of 5 trackpoints along the equator, from the longitude `from`.
fn segment(from: f64) -> TrackSegment {
let mut segment = TrackSegment::default();
let points = (0..5)
.map(|i| Trackpoint {
coordinates: LngLat {
lng: from + i as f64 * 0.001,
lat: 0.0,
},
..Default::default()
})
.collect();
segment.splice(0, 0, points);
segment
}
fn track(name: Option<&str>, from: &[f64]) -> Track {
Track {
info: TrackInfo {
name: name.map(str::to_string),
..Default::default()
},
trkseg: from.iter().map(|from| segment(*from)).collect(),
..Default::default()
}
}
fn waypoint(name: &str, lng: f64) -> Waypoint {
Waypoint {
name: Some(name.into()),
coordinates: LngLat { lng, lat: 0.001 },
..Default::default()
}
}
fn fixture(file: File) -> (Fixture, FileId) {
let mut fx = Fixture::default();
let id = file.id;
fx.files.insert(id, Rc::new(file));
fx.order.0.push(id);
fx.selection = Selection::File {
file_ids: [id].into(),
};
(fx, id)
}
fn file(name: &str, tracks: Vec<Track>, waypoints: Vec<Waypoint>) -> File {
let mut file = File {
trk: tracks,
..Default::default()
};
file.info.name = name.into();
file.wpt.insert_at(0, waypoints);
file
}
fn names(fx: &Fixture) -> Vec<String> {
fx.order
.0
.iter()
.map(|id| fx.files[id].info.name.clone())
.collect()
}
#[test]
fn test_a_file_with_one_track_becomes_one_file_per_segment() {
let original = file(
"route",
vec![track(Some("climb"), &[0.0, 10.0, 20.0])],
// near the first segment, near the last one, and in between, closer to the middle one
vec![
waypoint("a", 0.002),
waypoint("c", 20.002),
waypoint("b", 10.0),
],
);
let track_id = original.trk[0].id;
let segment_ids: Vec<_> = original.trk[0].trkseg.iter().map(|s| s.id).collect();
let (mut fx, id) = fixture(original);
let other = file("other", vec![track(None, &[50.0])], vec![]);
let other_id = other.id;
fx.files.insert(other_id, Rc::new(other));
fx.order.0.push(other_id);
Extract.apply(&mut fx.state()).unwrap();
// in the place of the file, in order, named after the track
assert!(!fx.files.contains_key(&id));
assert_eq!(names(&fx), ["climb (1)", "climb (2)", "climb (3)", "other"]);
assert_eq!(fx.order.0[3], other_id);
for (i, file_id) in fx.order.0[..3].iter().enumerate() {
let file = &fx.files[file_id];
assert_eq!(file.trk.len(), 1);
assert_eq!(file.trk[0].trkseg.len(), 1);
assert_eq!(file.trk[0].trkseg[0].id, segment_ids[i]);
assert_eq!(file.trk[0].info.name.as_deref(), Some("climb"));
}
assert_eq!(fx.files[&fx.order.0[0]].trk[0].id, track_id);
// the waypoints are with the closest segment
let waypoints = |i: usize| -> Vec<String> {
fx.files[&fx.order.0[i]]
.wpt
.iter()
.map(|w| w.name.clone().unwrap())
.collect()
};
assert_eq!(waypoints(0), ["a"]);
assert_eq!(waypoints(1), ["b"]);
assert_eq!(waypoints(2), ["c"]);
// the new files are selected, the other one is not
assert!(matches!(&fx.selection, Selection::File { file_ids }
if file_ids.len() == 3 && !file_ids.contains(&other_id)));
}
#[test]
fn test_a_file_with_several_tracks_becomes_one_file_per_track() {
let original = file(
"route",
vec![
track(Some("first"), &[0.0, 10.0]),
track(None, &[20.0]),
track(Some("third"), &[30.0, 40.0]),
],
vec![],
);
let ids: Vec<_> = original.trk.iter().map(|t| t.id).collect();
let (mut fx, _) = fixture(original);
Extract.apply(&mut fx.state()).unwrap();
// named after the track, or the file
assert_eq!(names(&fx), ["first", "route (2)", "third"]);
let tracks = |i: usize| &fx.files[&fx.order.0[i]].trk;
// a track with several segments is split in as many tracks
assert_eq!(tracks(0).len(), 2);
assert_eq!(tracks(0)[0].info.name.as_deref(), Some("first (1)"));
assert_eq!(tracks(0)[1].info.name.as_deref(), Some("first (2)"));
assert_eq!(tracks(0)[0].id, ids[0]);
// and one that has a single segment is not renamed
assert_eq!(tracks(1).len(), 1);
assert_eq!(tracks(1)[0].id, ids[1]);
assert_eq!(tracks(1)[0].info.name, None);
assert_eq!(tracks(2).len(), 2);
}
#[test]
fn test_waypoints_with_closest_segments_that_tie_are_copied() {
// the segments share a trackpoint, where the waypoint is: it is as close to both
let a = segment(0.0);
let b = segment(a[4].coordinates.lng);
let shared = Waypoint {
name: Some("mid".into()),
coordinates: a[4].coordinates,
..Default::default()
};
let original = file(
"f",
vec![Track {
trkseg: vec![a, b],
..Default::default()
}],
vec![shared, waypoint("lost", 100.0)],
);
let (mut fx, _) = fixture(original);
Extract.apply(&mut fx.state()).unwrap();
let first = &fx.files[&fx.order.0[0]];
let second = &fx.files[&fx.order.0[1]];
// the tie goes to both, with different ids
let mid_in = |f: &File| {
f.wpt
.iter()
.find(|w| w.name.as_deref() == Some("mid"))
.map(|w| w.id)
};
assert!(mid_in(first).is_some() && mid_in(second).is_some());
assert_ne!(mid_in(first), mid_in(second));
// a far waypoint goes to its closest segment, which is the last one
assert!(second.wpt.iter().any(|w| w.name.as_deref() == Some("lost")));
}
#[test]
fn test_a_waypoint_of_a_file_without_trackpoints_is_kept() {
let mut empty = TrackSegment::default();
empty.rev_id = Default::default();
let original = file(
"f",
vec![Track {
trkseg: vec![empty.clone(), TrackSegment::default()],
..Default::default()
}],
vec![waypoint("w", 1.0)],
);
let (mut fx, _) = fixture(original);
Extract.apply(&mut fx.state()).unwrap();
assert_eq!(fx.files[&fx.order.0[0]].wpt.len(), 1);
assert_eq!(fx.files[&fx.order.0[1]].wpt.len(), 0);
}
#[test]
fn test_extract_tracks_in_place() {
let original = file(
"route",
vec![
track(Some("a"), &[0.0]),
track(Some("b"), &[10.0, 20.0, 30.0]),
track(None, &[40.0, 50.0]),
],
vec![],
);
let ids: Vec<_> = original.trk.iter().map(|t| t.id).collect();
let (mut fx, id) = fixture(original);
fx.selection = Selection::Track {
file_id: id,
trk_ids: [ids[1], ids[2]].into(),
};
Extract.apply(&mut fx.state()).unwrap();
let file = &fx.files[&id];
let names: Vec<_> = file.trk.iter().map(|t| t.info.name.clone()).collect();
assert_eq!(
names,
[
Some("a".into()),
Some("b (1)".into()),
Some("b (2)".into()),
Some("b (3)".into()),
None,
None
]
);
assert_eq!(file.trk.len(), 6);
assert!(file.trk.iter().all(|t| t.trkseg.len() == 1));
assert_eq!(file.trk[0].id, ids[0]);
assert_eq!(file.trk[1].id, ids[1]);
// the new tracks are selected
let Selection::Track { trk_ids, .. } = &fx.selection else {
panic!("tracks are selected");
};
assert_eq!(trk_ids.len(), 5);
assert!(trk_ids.contains(&ids[1]) && !trk_ids.contains(&ids[0]));
}
#[test]
fn test_nothing_to_extract() {
// a single segment
let original = file("f", vec![track(Some("t"), &[0.0])], vec![]);
let track_id = original.trk[0].id;
let segment_id = original.trk[0].trkseg[0].id;
let (mut fx, id) = fixture(original);
let before = fx.files[&id].clone();
assert_eq!(
Extract.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
fx.selection = Selection::Track {
file_id: id,
trk_ids: [track_id].into(),
};
assert_eq!(
Extract.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
for selection in [
Selection::Empty,
Selection::Waypoints { file_id: id },
Selection::TrackSegment {
file_id: id,
trk_id: track_id,
trkseg_ids: [segment_id].into(),
},
] {
fx.selection = selection;
assert_eq!(
Extract.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
assert!(Rc::ptr_eq(&fx.files[&id], &before));
}
}
@@ -0,0 +1,230 @@
use std::rc::Rc;
use crate::{Apply, CommandError, LngLat, SegmentLocation, State, Trackpoint, distance, projected};
/// Under this distance (in meters) to a trackpoint, that trackpoint becomes the anchor instead of
/// a new one being inserted next to it.
const SNAP_DISTANCE: f64 = 1.0;
/// Makes an anchor of the routing tool, shown at every zoom level, of the point of the selected
/// segments that is the closest to the given coordinates. That is the trackpoint itself if the
/// coordinates are on one, otherwise a new trackpoint is inserted on the path, at the position
/// of the coordinates projected on it, with an elevation and a time in between the ones of its
/// neighbours.
#[derive(Debug)]
pub struct InsertAnchor {
pub lng: f64,
pub lat: f64,
}
/// Where the anchor goes: between the trackpoints `before` and `before + 1` of a segment, at
/// `ratio` of the distance from the first one, which is `projection`. `distance` is the one from
/// there to the coordinates, in meters.
struct Position {
location: SegmentLocation,
before: usize,
ratio: f64,
projection: LngLat,
distance: f64,
}
impl Apply for InsertAnchor {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let target = LngLat {
lng: self.lng,
lat: self.lat,
};
let mut closest: Option<Position> = None;
for location in state
.selection
.segment_locations(state.files, &state.order.0)
{
let segment = &state.files[&location.file_id].trk[location.trk].trkseg[location.seg];
let mut points = segment.iter();
let Some(mut previous) = points.next() else {
continue;
};
if segment.len() == 1 {
// a single point is its own path
let d = distance(previous.coordinates, target) * 1000.0;
if closest.as_ref().is_none_or(|c| d < c.distance) {
closest = Some(Position {
location,
before: 0,
ratio: 0.0,
projection: previous.coordinates,
distance: d,
});
}
}
for (i, point) in points.enumerate() {
let projection = projected(previous.coordinates, point.coordinates, target);
let d = distance(projection, target) * 1000.0;
if closest.as_ref().is_none_or(|c| d < c.distance) {
let length = distance(previous.coordinates, point.coordinates);
let ratio = if length > 0.0 {
distance(previous.coordinates, projection) / length
} else {
0.0
};
closest = Some(Position {
location,
before: i,
ratio,
projection,
distance: d,
});
}
previous = point;
}
}
let closest = closest.ok_or(CommandError::NothingToDo)?;
let mut file = (*state.files[&closest.location.file_id]).clone();
let segment = &mut file.trk[closest.location.trk].trkseg[closest.location.seg];
let before = closest.before;
// on a trackpoint already, if it is closer than `SNAP_DISTANCE`
let snapped = if segment.len() == 1 {
Some(0)
} else {
let (a, b) = (&segment[before], &segment[before + 1]);
let meters = 1000.0 * distance(a.coordinates, b.coordinates);
if closest.ratio * meters < SNAP_DISTANCE {
Some(before)
} else if (1.0 - closest.ratio) * meters < SNAP_DISTANCE {
Some(before + 1)
} else {
None
}
};
if let Some(index) = snapped {
if segment[index].anchor == Some(0) {
return Err(CommandError::NothingToDo);
}
segment.set_anchor(index, 0);
} else {
let (a, b) = (&segment[before], &segment[before + 1]);
let ratio = closest.ratio;
let mut point: Trackpoint = a.clone();
point.coordinates = closest.projection;
point.ele = (1.0 - ratio) * a.ele + ratio * b.ele;
point.time = a
.time
.zip(b.time)
.map(|(a, b)| ((1.0 - ratio) * a as f64 + ratio * b as f64) as i64);
point.anchor = Some(0);
segment.splice(before + 1, before + 1, vec![point]);
}
segment.rev_id = Default::default();
state.files.insert(closest.location.file_id, Rc::new(file));
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{FileId, Load, Selection, engine::command::fixture::Fixture};
use super::*;
fn loaded(path: &str) -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read(path).unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = fx.order.0[0];
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
(fx, id)
}
fn first_segment(fx: &Fixture, id: FileId) -> crate::TrackSegment {
fx.files[&id].trk[0].trkseg[0].clone()
}
#[test]
fn test_a_point_is_inserted_between_its_neighbours() {
let (mut fx, id) = loaded("data/with_time.gpx");
let before = first_segment(&fx, id);
let (a, b) = (&before[10], &before[11]);
// a bit off the line, closer to the middle of the two points
let target = LngLat {
lng: (a.coordinates.lng + b.coordinates.lng) / 2.0 + 1e-6,
lat: (a.coordinates.lat + b.coordinates.lat) / 2.0 + 1e-6,
};
InsertAnchor {
lng: target.lng,
lat: target.lat,
}
.apply(&mut fx.state())
.unwrap();
let after = first_segment(&fx, id);
assert_eq!(after.len(), before.len() + 1);
assert_ne!(after.rev_id, before.rev_id);
let point = &after[11];
assert_eq!(point.anchor, Some(0));
assert_eq!(after[10].coordinates.lng, a.coordinates.lng);
assert_eq!(after[12].coordinates.lng, b.coordinates.lng);
// between its neighbours, with the interpolated elevation and time
let ratio = (point.time.unwrap() - a.time.unwrap()) as f64
/ (b.time.unwrap() - a.time.unwrap()) as f64;
assert!((0.3..0.7).contains(&ratio), "{ratio}");
let expected = a.ele + ratio * (b.ele - a.ele);
assert!(
(point.ele - expected).abs() < 0.5,
"{} {expected}",
point.ele
);
}
#[test]
fn test_a_trackpoint_that_is_hit_becomes_the_anchor() {
let (mut fx, id) = loaded("data/with_time.gpx");
let before = first_segment(&fx, id);
assert_eq!(before[10].anchor.is_some(), before[10].anchor == Some(0));
let point = &before[10];
InsertAnchor {
lng: point.coordinates.lng,
lat: point.coordinates.lat,
}
.apply(&mut fx.state())
.unwrap();
let after = first_segment(&fx, id);
assert_eq!(after.len(), before.len());
let anchors = (0..after.len())
.filter(|i| after[*i].anchor == Some(0))
.count();
assert!(anchors >= 3, "{anchors}");
assert!(
after
.iter()
.zip(before.iter())
.all(|(a, b)| a.coordinates.lng == b.coordinates.lng)
);
// doing it again changes nothing
let again = InsertAnchor {
lng: point.coordinates.lng,
lat: point.coordinates.lat,
}
.apply(&mut fx.state());
assert!(again.is_ok() || again == Err(CommandError::NothingToDo));
}
#[test]
fn test_nothing_to_do_without_a_segment() {
let (mut fx, _) = loaded("data/with_time.gpx");
fx.selection = Selection::Empty;
assert_eq!(
InsertAnchor { lng: 1.0, lat: 1.0 }.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
}
+587
View File
@@ -0,0 +1,587 @@
use std::{collections::HashSet, rc::Rc};
use crate::{
Apply, CommandError, FileId, GlobalStatistics, Selection, State, Statistics, Track, TrackId,
TrackSegment, TrackSegmentId, Waypoint, Waypoints, replace_trackpoints,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum MergeType {
/// The trackpoints of the elements are connected in a single segment.
Connect,
/// The elements are put together, in the first one, which keeps them as they are.
Group,
}
/// Merges the selected elements into the first of them:
///
/// - files: the other files are deleted. The tracks of all of them are in the first file, as
/// they are with `Group`, or all connected in the first track with `Connect`. The waypoints of
/// all of them are in the first file too, except the ones that are the same.
/// - tracks of a file: the first track gets their segments, as they are with `Group`, or all
/// connected in one segment with `Connect`. The other tracks are removed.
/// - segments of a track: they are all connected in a segment at the position of the first one
/// with `Connect`. With `Group` they are put one after the other, at that position.
///
/// The segment made by `Connect` goes on in time, if there are timestamps, at the moving speed
/// of the selection, see [`replace_trackpoints`], which `remove_gaps` is given.
///
/// There is nothing to do when there is nothing to merge: a single file to group, a single track
/// that has no more than one segment, a single segment...
#[derive(Debug)]
pub struct Merge {
pub type_: MergeType,
pub remove_gaps: bool,
}
impl Apply for Merge {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let selection = state.selection.clone();
match &selection {
Selection::File { file_ids } => self.merge_files(state, file_ids),
Selection::Track { file_id, trk_ids } => self.merge_tracks(state, *file_id, trk_ids),
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => self.merge_segments(state, *file_id, *trk_id, trkseg_ids),
Selection::Empty | Selection::Waypoints { .. } | Selection::Waypoint { .. } => {
Err(CommandError::NothingToDo)
}
}
}
}
impl Merge {
fn merge_files(
&self,
state: &mut State,
file_ids: &HashSet<FileId>,
) -> Result<(), CommandError> {
// in the order of the files
let ordered: Vec<FileId> = state
.order
.0
.iter()
.filter(|id| file_ids.contains(id))
.chain(file_ids.iter().filter(|id| !state.order.0.contains(id)))
.filter(|id| state.files.contains_key(id))
.copied()
.collect();
let Some(&target_id) = ordered.first() else {
return Err(CommandError::NothingToDo);
};
let mut tracks: Vec<Track> = vec![];
let mut waypoints: Vec<Waypoint> = vec![];
for id in &ordered {
let file = &state.files[id];
tracks.extend(file.trk.iter().cloned());
for waypoint in file.wpt.iter() {
if !waypoints.iter().any(|kept| kept.same_content(waypoint)) {
waypoints.push(waypoint.clone());
}
}
}
let segments: Vec<TrackSegment> = tracks
.iter()
.flat_map(|track| track.trkseg.iter().cloned())
.collect();
match self.type_ {
MergeType::Group if ordered.len() == 1 => return Err(CommandError::NothingToDo),
MergeType::Connect if ordered.len() == 1 && segments.len() <= 1 => {
return Err(CommandError::NothingToDo);
}
MergeType::Connect if !segments.is_empty() => {
// a single track, which is the first one, with the connected segment
let merged = connect(&segments, segments[0].id, self.remove_gaps);
let first = Track {
trkseg: vec![merged],
..tracks[0].clone()
};
tracks = vec![first];
}
_ => {}
}
let mut target = (*state.files[&target_id]).clone();
target.trk = tracks;
target.wpt = Waypoints::default();
target.wpt.insert_at(0, waypoints);
for id in &ordered[1..] {
state.files.remove(id);
}
state.order.0.retain(|id| !ordered[1..].contains(id));
state.files.insert(target_id, Rc::new(target));
Ok(())
}
fn merge_tracks(
&self,
state: &mut State,
file_id: FileId,
trk_ids: &HashSet<TrackId>,
) -> Result<(), CommandError> {
let file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?;
let selected: Vec<usize> = (0..file.trk.len())
.filter(|i| trk_ids.contains(&file.trk[*i].id))
.collect();
let Some(&first) = selected.first() else {
return Err(CommandError::NothingToDo);
};
let segments: Vec<TrackSegment> = selected
.iter()
.flat_map(|i| file.trk[*i].trkseg.iter().cloned())
.collect();
if selected.len() == 1 && (self.type_ == MergeType::Group || segments.len() <= 1) {
return Err(CommandError::NothingToDo);
}
let merged = match self.type_ {
MergeType::Group => segments,
MergeType::Connect if segments.is_empty() => vec![],
MergeType::Connect => {
vec![connect(&segments, segments[0].id, self.remove_gaps)]
}
};
let mut file = (**file).clone();
file.trk[first].trkseg = merged;
let mut index = 0;
file.trk.retain(|_| {
index += 1;
!selected[1..].contains(&(index - 1))
});
state.files.insert(file_id, Rc::new(file));
Ok(())
}
fn merge_segments(
&self,
state: &mut State,
file_id: FileId,
trk_id: TrackId,
ids: &HashSet<TrackSegmentId>,
) -> Result<(), CommandError> {
let file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?;
let trk = file
.trk
.iter()
.position(|trk| trk.id == trk_id)
.ok_or(CommandError::NothingToDo)?;
let segments = &file.trk[trk].trkseg;
let selected: Vec<usize> = (0..segments.len())
.filter(|i| ids.contains(&segments[*i].id))
.collect();
if selected.len() < 2 {
return Err(CommandError::NothingToDo);
}
let first = selected[0];
let chosen: Vec<TrackSegment> = selected.iter().map(|i| segments[*i].clone()).collect();
let merged = match self.type_ {
MergeType::Connect => vec![connect(&chosen, chosen[0].id, self.remove_gaps)],
MergeType::Group => {
if selected.iter().enumerate().all(|(i, s)| *s == first + i) {
// already one after the other
return Err(CommandError::NothingToDo);
}
chosen
}
};
let mut file = (**file).clone();
let mut remaining: Vec<TrackSegment> = file.trk[trk]
.trkseg
.iter()
.filter(|segment| !ids.contains(&segment.id))
.cloned()
.collect();
// the ones before the first selected one are all kept
remaining.splice(first..first, merged);
file.trk[trk].trkseg = remaining;
state.files.insert(file_id, Rc::new(file));
Ok(())
}
}
/// The segments connected in a single one, which has the id `id`: the trackpoints of the second
/// go after the ones of the first and so on.
///
/// If there are timestamps, the new segment goes on in time, see [`replace_trackpoints`]: the
/// trackpoints that have none get some, at the moving speed of the segments, and the ones that
/// are too early follow the previous ones. The time of the first trackpoint is the one it has, or
/// the one it would have if it was as far in time from the first timed one as its distance
/// says.
fn connect(segments: &[TrackSegment], id: TrackSegmentId, remove_gaps: bool) -> TrackSegment {
let mut global = GlobalStatistics::default();
let mut distance = 0.0;
// the first time, and the distance from the start at which it is
let mut first_timed: Option<(i64, f64)> = None;
for segment in segments {
let stats = Statistics::compute(segment);
if first_timed.is_none() {
first_timed = segment.iter().zip(&stats.local).find_map(|(point, local)| {
point
.time
.map(|time| (time, distance + local.total_distance))
});
}
distance += stats.global.total_distance;
global.merge(&stats.global);
}
let speed = global.moving_speed().filter(|speed| *speed > 0.0);
let start_time = speed
.and_then(|speed| first_timed.map(|(time, km)| time - (3_600_000.0 * km / speed) as i64));
let mut points = vec![];
for segment in segments {
let end = points.len();
replace_trackpoints(
&mut points,
end,
end,
segment.iter().cloned().collect(),
speed,
start_time,
remove_gaps,
);
}
let mut merged = TrackSegment::default();
merged.splice(0, 0, points);
merged.id = id;
merged
}
#[cfg(test)]
mod tests {
use crate::{File, Load, engine::command::fixture::Fixture};
use super::*;
fn load(fx: &mut Fixture, path: &str) -> FileId {
let data = std::fs::read(path).unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
*fx.order.0.last().unwrap()
}
fn merge(type_: MergeType) -> Merge {
Merge {
type_,
remove_gaps: false,
}
}
fn select_files(fx: &mut Fixture, ids: &[FileId]) {
fx.selection = Selection::File {
file_ids: ids.iter().copied().collect(),
};
}
fn points(file: &File) -> usize {
file.trk
.iter()
.flat_map(|t| &t.trkseg)
.map(|s| s.len())
.sum()
}
fn waypoint(name: &str) -> Waypoint {
Waypoint {
name: Some(name.into()),
..Default::default()
}
}
#[test]
fn test_group_files() {
let mut fx = Fixture::default();
let a = load(&mut fx, "data/with_tracks_and_segments.gpx");
let b = load(&mut fx, "data/simple.gpx");
let mut file_a = (*fx.files[&a]).clone();
file_a
.wpt
.insert_at(0, vec![waypoint("x"), waypoint("same")]);
fx.files.insert(a, Rc::new(file_a));
let mut file_b = (*fx.files[&b]).clone();
file_b
.wpt
.insert_at(0, vec![waypoint("same"), waypoint("y")]);
fx.files.insert(b, Rc::new(file_b));
let (before_a, before_b) = (fx.files[&a].clone(), fx.files[&b].clone());
select_files(&mut fx, &[b, a]);
merge(MergeType::Group).apply(&mut fx.state()).unwrap();
// the first file (in the order of the files) gets everything, the other one goes
assert_eq!(fx.order.0, [a]);
assert!(!fx.files.contains_key(&b));
let merged = &fx.files[&a];
assert_eq!(merged.trk.len(), before_a.trk.len() + before_b.trk.len());
assert_eq!(merged.trk[0].id, before_a.trk[0].id);
assert_eq!(
merged.trk.last().unwrap().id,
before_b.trk.last().unwrap().id
);
assert_eq!(points(merged), points(&before_a) + points(&before_b));
// the waypoints that are the same are only once
let names: Vec<_> = merged.wpt.iter().map(|w| w.name.clone().unwrap()).collect();
assert_eq!(names, ["x", "same", "y"]);
assert_eq!(merged.info, before_a.info);
// a single file has nothing to group
select_files(&mut fx, &[a]);
assert_eq!(
merge(MergeType::Group).apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
#[test]
fn test_connect_files() {
let mut fx = Fixture::default();
let a = load(&mut fx, "data/with_tracks_and_segments.gpx");
let b = load(&mut fx, "data/simple.gpx");
let (before_a, before_b) = (fx.files[&a].clone(), fx.files[&b].clone());
select_files(&mut fx, &[a, b]);
merge(MergeType::Connect).apply(&mut fx.state()).unwrap();
let merged = &fx.files[&a];
assert_eq!(fx.order.0, [a]);
// a single track, with a single segment that has all the trackpoints in order
assert_eq!(merged.trk.len(), 1);
assert_eq!(merged.trk[0].id, before_a.trk[0].id);
assert_eq!(merged.trk[0].trkseg.len(), 1);
let segment = &merged.trk[0].trkseg[0];
assert_eq!(segment.len(), points(&before_a) + points(&before_b));
let expected: Vec<f64> = before_a
.trk
.iter()
.chain(&before_b.trk)
.flat_map(|t| &t.trkseg)
.flat_map(|s| s.iter().map(|p| p.coordinates.lng))
.collect();
let lngs: Vec<f64> = segment.iter().map(|p| p.coordinates.lng).collect();
assert_eq!(lngs, expected);
assert_eq!(segment.id, before_a.trk[0].trkseg[0].id);
assert_eq!(segment[0].anchor, Some(0));
}
#[test]
fn test_connect_goes_on_in_time() {
let mut fx = Fixture::default();
// the same times twice: the second part would be too early
let a = load(&mut fx, "data/with_time.gpx");
let b = load(&mut fx, "data/with_time.gpx");
select_files(&mut fx, &[a, b]);
merge(MergeType::Connect).apply(&mut fx.state()).unwrap();
let segment = &fx.files[&a].trk[0].trkseg[0];
let times: Vec<i64> = segment.iter().map(|p| p.time.unwrap()).collect();
assert_eq!(times.len(), 160);
assert!(times.windows(2).all(|w| w[0] <= w[1]), "{times:?}");
assert!(times[80] > times[79]);
}
#[test]
fn test_connect_without_gaps_brings_the_parts_closer() {
let mut fx = Fixture::default();
let a = load(&mut fx, "data/with_time.gpx");
// the second file comes a day later
let b = {
let data = std::fs::read("data/with_time.gpx").unwrap();
Load {
data: &data,
name: "late",
}
.apply(&mut fx.state())
.unwrap();
*fx.order.0.last().unwrap()
};
let mut file = (*fx.files[&b]).clone();
for track in file.trk.iter_mut() {
for segment in track.trkseg.iter_mut() {
let points: Vec<_> = segment
.iter()
.map(|p| {
let mut p = p.clone();
p.time = p.time.map(|t| t + 86_400_000);
p
})
.collect();
let len = segment.len();
segment.splice(0, len, points);
}
}
fx.files.insert(b, Rc::new(file));
let gap = |fx: &Fixture, remove_gaps: bool| {
let mut fx2 = Fixture {
files: fx.files.clone(),
order: crate::FileOrder(fx.order.0.clone()),
selection: Selection::File {
file_ids: [a, b].into(),
},
..Default::default()
};
Merge {
type_: MergeType::Connect,
remove_gaps,
}
.apply(&mut fx2.state())
.unwrap();
let segment = &fx2.files[&a].trk[0].trkseg[0];
segment[80].time.unwrap() - segment[79].time.unwrap()
};
let with_gap = gap(&fx, false);
let without = gap(&fx, true);
assert!(with_gap > 80_000_000, "{with_gap}");
assert!(without < 60_000, "{without}");
}
#[test]
fn test_merge_tracks() {
let mut fx = Fixture::default();
let id = load(&mut fx, "data/with_tracks_and_segments.gpx");
let file = fx.files[&id].clone();
assert!(file.trk.len() >= 2);
let ids: HashSet<TrackId> = file.trk.iter().map(|t| t.id).collect();
fx.selection = Selection::Track {
file_id: id,
trk_ids: ids,
};
let segments: usize = file.trk.iter().map(|t| t.trkseg.len()).sum();
merge(MergeType::Group).apply(&mut fx.state()).unwrap();
let merged = &fx.files[&id];
assert_eq!(merged.trk.len(), 1);
assert_eq!(merged.trk[0].id, file.trk[0].id);
assert_eq!(merged.trk[0].trkseg.len(), segments);
assert_eq!(points(merged), points(&file));
// connected: a single segment
fx.files.insert(id, file.clone());
merge(MergeType::Connect).apply(&mut fx.state()).unwrap();
let merged = &fx.files[&id];
assert_eq!(merged.trk.len(), 1);
assert_eq!(merged.trk[0].trkseg.len(), 1);
assert_eq!(merged.trk[0].trkseg[0].len(), points(&file));
// one track with a single segment: nothing to do
fx.selection = Selection::Track {
file_id: id,
trk_ids: [file.trk[0].id].into(),
};
for type_ in [MergeType::Group, MergeType::Connect] {
assert_eq!(
merge(type_).apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
}
#[test]
fn test_connect_the_segments_of_a_track() {
let mut fx = Fixture::default();
let id = load(&mut fx, "data/with_tracks_and_segments.gpx");
let file = fx.files[&id].clone();
let (index, track) = file
.trk
.iter()
.enumerate()
.find(|(_, t)| t.trkseg.len() >= 2)
.unwrap();
fx.selection = Selection::Track {
file_id: id,
trk_ids: [track.id].into(),
};
merge(MergeType::Connect).apply(&mut fx.state()).unwrap();
let merged = &fx.files[&id].trk[index];
assert_eq!(merged.id, track.id);
assert_eq!(merged.trkseg.len(), 1);
assert_eq!(
merged.trkseg[0].len(),
track.trkseg.iter().map(|s| s.len()).sum::<usize>()
);
// the other tracks are untouched
assert_eq!(fx.files[&id].trk.len(), file.trk.len());
}
#[test]
fn test_merge_segments() {
let mut fx = Fixture::default();
let id = load(&mut fx, "data/with_tracks_and_segments.gpx");
// a track with three segments
let mut three = (*fx.files[&id]).clone();
let base = three.trk[0].trkseg[0].clone();
three.trk[0].trkseg = (0..3).map(|_| crate::copy_segment(&base)).collect();
fx.files.insert(id, Rc::new(three));
let file = fx.files[&id].clone();
let (index, track) = (0, &file.trk[0]);
let segments = &track.trkseg;
// the first and the last ones
let (first, last) = (&segments[0], &segments[2]);
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id: track.id,
trkseg_ids: [first.id, last.id].into(),
};
// grouped: next to each other, at the place of the first one
merge(MergeType::Group).apply(&mut fx.state()).unwrap();
let ids: Vec<_> = fx.files[&id].trk[index]
.trkseg
.iter()
.map(|s| s.id)
.collect();
assert_eq!(ids[..3], [first.id, last.id, segments[1].id]);
// connected: one segment, with the id of the first one, at its place
fx.files.insert(id, file.clone());
merge(MergeType::Connect).apply(&mut fx.state()).unwrap();
let merged = &fx.files[&id].trk[index].trkseg;
assert_eq!(merged.len(), segments.len() - 1);
assert_eq!(merged[0].id, first.id);
assert_eq!(merged[0].len(), first.len() + last.len());
assert_eq!(merged[1].id, segments[1].id);
// a single segment, or segments that are already together: nothing to do
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id: track.id,
trkseg_ids: [first.id].into(),
};
assert_eq!(
merge(MergeType::Connect).apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
fx.files.insert(id, file.clone());
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id: track.id,
trkseg_ids: [first.id, segments[1].id].into(),
};
assert_eq!(
merge(MergeType::Group).apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
#[test]
fn test_nothing_to_merge() {
let mut fx = Fixture::default();
let id = load(&mut fx, "data/simple.gpx");
let before = fx.files[&id].clone();
for selection in [Selection::Empty, Selection::Waypoints { file_id: id }] {
fx.selection = selection;
assert_eq!(
merge(MergeType::Connect).apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
assert!(Rc::ptr_eq(&fx.files[&id], &before));
}
}
+33
View File
@@ -0,0 +1,33 @@
mod change_loop_start;
mod clean;
mod crop;
mod edit_waypoint;
mod elevation;
mod extract;
mod insert_anchor;
mod merge;
mod move_waypoint;
mod new_waypoint;
mod reduce;
mod reverse;
mod round_trip;
mod route;
mod split;
mod time;
pub use change_loop_start::*;
pub use clean::*;
pub use crop::*;
pub use edit_waypoint::*;
pub use elevation::*;
pub use extract::*;
pub use insert_anchor::*;
pub use merge::*;
pub use move_waypoint::*;
pub use new_waypoint::*;
pub use reduce::*;
pub use reverse::*;
pub use round_trip::*;
pub use route::*;
pub use split::*;
pub use time::*;
@@ -0,0 +1,107 @@
use crate::{Apply, CommandError, FileId, LngLat, State, WaypointId, update_waypoint};
/// Moves a waypoint of a file, whatever is selected (what a drag does): only its position and
/// elevation change.
#[derive(Debug)]
pub struct MoveWaypoint {
pub file_id: FileId,
pub waypoint_id: WaypointId,
pub lng: f64,
pub lat: f64,
pub ele: f64,
}
impl Apply for MoveWaypoint {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
update_waypoint(state, self.file_id, self.waypoint_id, |wpt| {
wpt.coordinates = LngLat {
lng: self.lng,
lat: self.lat,
};
wpt.ele = self.ele;
})
}
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use crate::{
File, Selection, Waypoint, WaypointChunk, engine::command::fixture::Fixture, waypoint_ids,
};
use super::*;
fn fixture() -> (Fixture, crate::FileId, Vec<crate::WaypointId>) {
let mut fx = Fixture::default();
let mut file = File::default();
file.wpt.push(WaypointChunk {
wpt: (0..3)
.map(|i| Waypoint {
name: Some(format!("w{i}")),
..Default::default()
})
.collect(),
..Default::default()
});
let id = file.id;
let ids = waypoint_ids(&file).collect();
fx.files.insert(id, Rc::new(file));
fx.order.0.push(id);
(fx, id, ids)
}
#[test]
fn test_only_the_given_waypoint_moves_whatever_is_selected() {
let (mut fx, id, ids) = fixture();
let rev = fx.files[&id].wpt.rev_id;
MoveWaypoint {
file_id: id,
waypoint_id: ids[1],
lng: 4.0,
lat: 50.0,
ele: 42.0,
}
.apply(&mut fx.state())
.unwrap();
let wpts: Vec<_> = fx.files[&id].wpt.iter().collect();
assert_eq!(wpts.len(), 3);
assert_eq!(
(
wpts[1].coordinates.lng,
wpts[1].coordinates.lat,
wpts[1].ele
),
(4.0, 50.0, 42.0)
);
// the other fields and the other waypoints are as they were
assert_eq!(wpts[1].name.as_deref(), Some("w1"));
assert_eq!(wpts[1].id, ids[1]);
assert_eq!((wpts[0].coordinates.lng, wpts[0].ele), (0.0, 0.0));
assert_eq!((wpts[2].coordinates.lat, wpts[2].ele), (0.0, 0.0));
// the coordinates changed, which is noticed by what is derived from them
assert_ne!(fx.files[&id].wpt.rev_id, rev);
assert_eq!(fx.selection, Selection::Empty);
}
#[test]
fn test_unknown_waypoint_or_file_is_nothing_to_do() {
let (mut fx, id, ids) = fixture();
let before = fx.files[&id].clone();
for (file_id, waypoint_id) in [(id, WaypointId::default()), (FileId::default(), ids[0])] {
let result = MoveWaypoint {
file_id,
waypoint_id,
lng: 1.0,
lat: 1.0,
ele: 1.0,
}
.apply(&mut fx.state());
assert!(matches!(result, Err(CommandError::NothingToDo)));
}
assert!(Rc::ptr_eq(&fx.files[&id], &before));
}
}
@@ -0,0 +1,270 @@
use std::rc::Rc;
use crate::{Apply, CommandError, File, FileId, Link, LngLat, Selection, State, Waypoint};
/// Adds a waypoint at the end of the waypoints of each selected file (or of the file of the
/// selected elements). The strings are empty when the waypoint does not have the field.
#[derive(Debug)]
pub struct NewWaypoint<'a> {
pub lng: f64,
pub lat: f64,
pub ele: f64,
pub name: &'a str,
pub desc: &'a str,
pub icon: &'a str,
pub link: &'a str,
}
fn non_empty(text: &str) -> Option<String> {
(!text.is_empty()).then(|| text.to_owned())
}
/// Sets the fields of a waypoint: the strings that are empty remove the field.
pub fn set_waypoint_fields(
waypoint: &mut Waypoint,
position: (f64, f64, f64),
name: &str,
desc: &str,
icon: &str,
link: &str,
) {
let (lng, lat, ele) = position;
waypoint.coordinates = LngLat { lng, lat };
waypoint.ele = ele;
waypoint.name = non_empty(name);
waypoint.desc = non_empty(desc);
// the description is also the comment, as it is what the other applications show
waypoint.cmt = non_empty(desc);
waypoint.sym = non_empty(icon);
// the form has a single link: it is the first one, the others are left as they are
match (non_empty(link), waypoint.links.first_mut()) {
(Some(href), Some(first)) if first.href != href => *first = Link { href, text: None },
(Some(href), None) => waypoint.links.push(Link { href, text: None }),
(None, Some(_)) => {
waypoint.links.remove(0);
}
_ => (),
}
}
impl Apply for NewWaypoint<'_> {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let file_ids: Vec<FileId> = match &*state.selection {
Selection::Empty => vec![],
Selection::File { file_ids } => state
.order
.0
.iter()
.filter(|id| file_ids.contains(id))
.copied()
.collect(),
Selection::Track { file_id, .. }
| Selection::TrackSegment { file_id, .. }
| Selection::Waypoints { file_id }
| Selection::Waypoint { file_id, .. } => vec![*file_id],
};
let file_ids: Vec<FileId> = file_ids
.into_iter()
.filter(|id| state.files.contains_key(id))
.collect();
if file_ids.is_empty() {
return Err(CommandError::NothingToDo);
}
for id in file_ids {
let mut waypoint = Waypoint::default();
set_waypoint_fields(
&mut waypoint,
(self.lng, self.lat, self.ele),
self.name,
self.desc,
self.icon,
self.link,
);
let file: &mut File = Rc::make_mut(state.files.get_mut(&id).unwrap());
file.wpt.insert_at(usize::MAX, vec![waypoint]);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{New, TrackId, TrackSegmentId, engine::command::fixture::Fixture, waypoint_ids};
use super::*;
fn new_waypoint(name: &str) -> NewWaypoint<'_> {
NewWaypoint {
lng: 4.5,
lat: 50.25,
ele: 123.0,
name,
desc: "a description",
icon: "Summit",
link: "https://example.com",
}
}
fn waypoints(fx: &Fixture, id: FileId) -> Vec<Waypoint> {
fx.files[&id].wpt.iter().cloned().collect()
}
#[test]
fn test_new_waypoint_in_the_selected_file() {
let mut fx = Fixture::default();
New {
name: "a",
trackpoint: None,
}
.apply(&mut fx.state())
.unwrap();
New {
name: "b",
trackpoint: None,
}
.apply(&mut fx.state())
.unwrap();
let (a, b) = (fx.order.0[0], fx.order.0[1]);
let rev = fx.files[&b].wpt.rev_id;
// b is selected
new_waypoint("peak").apply(&mut fx.state()).unwrap();
assert!(waypoints(&fx, a).is_empty());
let created = waypoints(&fx, b);
assert_eq!(created.len(), 1);
let wpt = &created[0];
assert_eq!(
(wpt.coordinates.lng, wpt.coordinates.lat, wpt.ele),
(4.5, 50.25, 123.0)
);
assert_eq!(wpt.name.as_deref(), Some("peak"));
assert_eq!(wpt.desc.as_deref(), Some("a description"));
assert_eq!(wpt.cmt.as_deref(), Some("a description"));
assert_eq!(wpt.sym.as_deref(), Some("Summit"));
assert_eq!(
wpt.links.first().map(|l| l.href.as_str()),
Some("https://example.com")
);
assert_ne!(fx.files[&b].wpt.rev_id, rev);
// added after the others
let mut second = new_waypoint("second");
second.lng = 5.0;
second.apply(&mut fx.state()).unwrap();
let names: Vec<_> = waypoints(&fx, b)
.iter()
.map(|w| w.name.clone().unwrap())
.collect();
assert_eq!(names, ["peak", "second"]);
// and not selected
assert_eq!(fx.selected_files(), [b].into());
}
#[test]
fn test_empty_fields_are_not_set() {
let mut fx = Fixture::default();
New {
name: "a",
trackpoint: None,
}
.apply(&mut fx.state())
.unwrap();
let a = fx.order.0[0];
NewWaypoint {
lng: 1.0,
lat: 2.0,
ele: 0.0,
name: "",
desc: "",
icon: "",
link: "",
}
.apply(&mut fx.state())
.unwrap();
let wpt = &waypoints(&fx, a)[0];
assert!(wpt.name.is_none() && wpt.desc.is_none() && wpt.cmt.is_none());
assert!(wpt.sym.is_none() && wpt.links.is_empty());
}
#[test]
fn test_each_selected_file_gets_its_own_waypoint() {
let mut fx = Fixture::default();
for name in ["a", "b", "c"] {
New {
name,
trackpoint: None,
}
.apply(&mut fx.state())
.unwrap();
}
let ids = fx.order.0.clone();
fx.selection = Selection::File {
file_ids: HashSet::from([ids[0], ids[2]]),
};
new_waypoint("w").apply(&mut fx.state()).unwrap();
assert_eq!(waypoints(&fx, ids[0]).len(), 1);
assert!(waypoints(&fx, ids[1]).is_empty());
assert_eq!(waypoints(&fx, ids[2]).len(), 1);
// not the same waypoint
assert_ne!(waypoints(&fx, ids[0])[0].id, waypoints(&fx, ids[2])[0].id);
}
#[test]
fn test_the_file_of_the_selected_elements_is_used() {
for selection in [
|id| Selection::Track {
file_id: id,
trk_ids: [TrackId::default()].into(),
},
|id| Selection::TrackSegment {
file_id: id,
trk_id: TrackId::default(),
trkseg_ids: [TrackSegmentId::default()].into(),
},
|id| Selection::Waypoints { file_id: id },
|id| Selection::Waypoint {
file_id: id,
wpt_ids: HashSet::new(),
},
] {
let mut fx = Fixture::default();
New {
name: "a",
trackpoint: None,
}
.apply(&mut fx.state())
.unwrap();
let a = fx.order.0[0];
fx.selection = selection(a);
new_waypoint("w").apply(&mut fx.state()).unwrap();
assert_eq!(waypoint_ids(&fx.files[&a]).count(), 1);
}
}
#[test]
fn test_nothing_selected_or_unknown_file() {
let mut fx = Fixture::default();
New {
name: "a",
trackpoint: None,
}
.apply(&mut fx.state())
.unwrap();
fx.selection = Selection::Empty;
assert_eq!(
new_waypoint("w").apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
fx.selection = Selection::Waypoints {
file_id: FileId::default(),
};
assert_eq!(
new_waypoint("w").apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
assert_eq!(fx.files.len(), 1);
}
}
+149
View File
@@ -0,0 +1,149 @@
use crate::{Apply, CommandError, State, reduce_indices, update_segments};
/// Removes the trackpoints of the selected segments that are less than `tolerance` meters away
/// from the line of their neighbours (Ramer-Douglas-Peucker, see [`reduce_indices`]). The ends of the segments are
/// kept.
#[derive(Debug)]
pub struct Reduce {
pub tolerance: f64,
}
impl Apply for Reduce {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
if !(self.tolerance.is_finite() && self.tolerance > 0.0) {
return Err(CommandError::InvalidData(format!(
"tolerance {} should be positive",
self.tolerance
)));
}
let locations = state
.selection
.segment_locations(state.files, &state.order.0);
let mut changed = vec![];
let mut kept_points = vec![];
for location in &locations {
let segment = &state.files[&location.file_id].trk[location.trk].trkseg[location.seg];
let kept = reduce_indices(segment, self.tolerance);
if kept.len() == segment.len() {
continue;
}
changed.push(*location);
kept_points.push(kept.iter().map(|&i| segment[i].clone()).collect::<Vec<_>>());
}
if changed.is_empty() {
return Err(CommandError::NothingToDo);
}
update_segments(state, &changed, |i, segment| {
let len = segment.len();
segment.splice(0, len, std::mem::take(&mut kept_points[i]));
});
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{FileId, Load, Selection, engine::command::fixture::Fixture, reduction_distances};
use super::*;
fn loaded(path: &str) -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read(path).unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = fx.order.0[0];
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
(fx, id)
}
fn count(fx: &Fixture, id: FileId) -> usize {
fx.files[&id]
.trk
.iter()
.flat_map(|trk| &trk.trkseg)
.map(|seg| seg.len())
.sum()
}
#[test]
fn test_a_larger_tolerance_keeps_fewer_points_and_the_ends() {
let (mut fx, id) = loaded("data/with_time.gpx");
let before = count(&fx, id);
let first = fx.files[&id].trk[0].trkseg[0][0].coordinates;
Reduce { tolerance: 5.0 }.apply(&mut fx.state()).unwrap();
let medium = count(&fx, id);
assert!(medium < before);
assert_eq!(fx.files[&id].trk[0].trkseg[0][0].coordinates.lng, first.lng);
Reduce { tolerance: 500.0 }.apply(&mut fx.state()).unwrap();
assert!(count(&fx, id) < medium);
}
#[test]
fn test_the_distances_tell_which_points_are_kept() {
for path in ["data/with_time.gpx", "data/with_tracks_and_segments.gpx"] {
let (fx, id) = loaded(path);
for seg in fx.files[&id].trk.iter().flat_map(|trk| &trk.trkseg) {
let distances = reduction_distances(seg);
for tolerance in [0.1, 0.5, 1.0, 3.0, 10.0, 30.0, 100.0, 1000.0] {
let from_distances: Vec<usize> = (0..seg.len())
.filter(|&i| distances[i] > tolerance)
.collect();
assert_eq!(
from_distances,
reduce_indices(seg, tolerance),
"{path} {tolerance}"
);
}
}
}
}
#[test]
fn test_only_the_selected_segments_are_reduced() {
let (mut fx, id) = loaded("data/with_time.gpx");
let others: usize = fx.files[&id]
.trk
.iter()
.flat_map(|t| &t.trkseg)
.skip(1)
.map(|s| s.len())
.sum();
let expected = reduce_indices(&fx.files[&id].trk[0].trkseg[0], 5.0).len();
fx.selection = {
let track = &fx.files[&id].trk[0];
Selection::TrackSegment {
file_id: id,
trk_id: track.id,
trkseg_ids: HashSet::from([track.trkseg[0].id]),
}
};
Reduce { tolerance: 5.0 }.apply(&mut fx.state()).unwrap();
assert_eq!(count(&fx, id), expected + others);
}
#[test]
fn test_invalid_tolerance_and_nothing_to_remove() {
let (mut fx, _) = loaded("data/simple.gpx");
assert!(matches!(
Reduce { tolerance: 0.0 }.apply(&mut fx.state()),
Err(CommandError::InvalidData(_))
));
assert!(matches!(
Reduce { tolerance: 1e-9 }.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
));
}
}
+294
View File
@@ -0,0 +1,294 @@
use std::rc::Rc;
use crate::{
Apply, CommandError, File, FileId, Selection, State, TrackId, TrackSegmentId, end_time,
reverse_segment, reverse_segments, reverse_tracks, start_time, track_end_time,
track_start_time,
};
/// Reverses the direction of the selected elements: the whole of each selected file (its tracks,
/// the segments of its tracks and their trackpoints are in the opposite order), of each selected
/// track (its segments and their trackpoints), or each selected segment on its own (its
/// trackpoints). The timestamps are mirrored, see [`reverse_segment`].
#[derive(Debug)]
pub struct Reverse;
impl Apply for Reverse {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let locations = state
.selection
.segment_locations(state.files, &state.order.0);
let points: usize = locations
.iter()
.map(|location| {
state.files[&location.file_id].trk[location.trk].trkseg[location.seg].len()
})
.sum();
if points <= 1 {
return Err(CommandError::NothingToDo);
}
let mut files: Vec<(FileId, File)> = vec![];
let mut edit = |file_id: &FileId, f: &dyn Fn(&mut File)| {
if let Some(file) = state.files.get(file_id) {
let mut file = (**file).clone();
f(&mut file);
files.push((*file_id, file));
}
};
match &*state.selection {
Selection::File { file_ids } => {
for file_id in file_ids {
edit(file_id, &|file| {
let end = file.trk.last().and_then(track_end_time);
let start = file.trk.first().and_then(track_start_time);
reverse_tracks(&mut file.trk, end, start);
});
}
}
Selection::Track { file_id, trk_ids } => edit(file_id, &|file| {
reverse_selected_tracks(file, trk_ids);
}),
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => edit(file_id, &|file| {
reverse_selected_segments(file, *trk_id, trkseg_ids);
}),
Selection::Empty | Selection::Waypoints { .. } | Selection::Waypoint { .. } => {}
}
for (file_id, file) in files {
state.files.insert(file_id, Rc::new(file));
}
Ok(())
}
}
fn reverse_selected_tracks(file: &mut File, ids: &std::collections::HashSet<TrackId>) {
for track in file.trk.iter_mut().filter(|track| ids.contains(&track.id)) {
let end = track.trkseg.last().and_then(end_time);
let start = track.trkseg.first().and_then(start_time);
reverse_segments(&mut track.trkseg, end, start);
}
}
fn reverse_selected_segments(
file: &mut File,
track_id: TrackId,
ids: &std::collections::HashSet<TrackSegmentId>,
) {
for track in file.trk.iter_mut().filter(|track| track.id == track_id) {
for segment in track
.trkseg
.iter_mut()
.filter(|segment| ids.contains(&segment.id))
{
let (end, start) = (end_time(segment), start_time(segment));
reverse_segment(segment, end, start);
}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{Load, TrackSegment, engine::command::fixture::Fixture};
use super::*;
fn loaded(path: &str) -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read(path).unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = fx.order.0[0];
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
(fx, id)
}
fn all_segments(fx: &Fixture, id: FileId) -> Vec<TrackSegment> {
fx.files[&id]
.trk
.iter()
.flat_map(|track| track.trkseg.iter().cloned())
.collect()
}
fn lngs(segment: &TrackSegment) -> Vec<f64> {
segment.iter().map(|p| p.coordinates.lng).collect()
}
#[test]
fn test_reversing_a_file_reverses_tracks_segments_and_points() {
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
let before = all_segments(&fx, id);
assert!(before.len() >= 3);
let (tracks_before, ids_before): (Vec<usize>, Vec<_>) = (
fx.files[&id].trk.iter().map(|t| t.trkseg.len()).collect(),
fx.files[&id].trk.iter().map(|t| t.id).collect(),
);
Reverse.apply(&mut fx.state()).unwrap();
let after = all_segments(&fx, id);
assert_eq!(after.len(), before.len());
// the whole path is the opposite one
let path =
|segments: &[TrackSegment]| -> Vec<f64> { segments.iter().flat_map(lngs).collect() };
let mut expected = path(&before);
expected.reverse();
assert_eq!(path(&after), expected);
// the tracks are in the opposite order, each with the same number of segments
let tracks_after: Vec<usize> = fx.files[&id].trk.iter().map(|t| t.trkseg.len()).collect();
let mut reversed = tracks_before.clone();
reversed.reverse();
assert_eq!(tracks_after, reversed);
let ids_after: Vec<_> = fx.files[&id].trk.iter().map(|t| t.id).collect();
let mut reversed_ids = ids_before;
reversed_ids.reverse();
assert_eq!(ids_after, reversed_ids);
// the segments changed, and they still end where they should
assert!(after.iter().all(|s| s.rev_id != before[0].rev_id));
for segment in &after {
assert_eq!(segment[0].anchor, Some(0));
assert_eq!(segment[segment.len() - 1].anchor, Some(0));
}
}
#[test]
fn test_reversing_a_file_twice_gives_the_same_path_and_times() {
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
let before = all_segments(&fx, id);
let times = |segments: &[TrackSegment]| -> Vec<Option<i64>> {
segments
.iter()
.flat_map(|s| s.iter().map(|p| p.time))
.collect()
};
Reverse.apply(&mut fx.state()).unwrap();
Reverse.apply(&mut fx.state()).unwrap();
let after = all_segments(&fx, id);
for (a, b) in before.iter().zip(&after) {
assert_eq!(lngs(a), lngs(b));
assert_eq!(a.id, b.id);
}
assert_eq!(times(&before), times(&after));
}
#[test]
fn test_reversing_segments_keeps_the_others() {
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
let file = fx.files[&id].clone();
let trk = &file.trk[0];
assert!(trk.trkseg.len() >= 2);
let target = &trk.trkseg[1];
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id: trk.id,
trkseg_ids: HashSet::from([target.id]),
};
Reverse.apply(&mut fx.state()).unwrap();
let after = &fx.files[&id].trk[0];
// the segment is reversed in place
let mut expected = lngs(target);
expected.reverse();
assert_eq!(lngs(&after.trkseg[1]), expected);
assert_eq!(after.trkseg[1].id, target.id);
assert_ne!(after.trkseg[1].rev_id, target.rev_id);
// the other ones are the same
for (i, segment) in trk.trkseg.iter().enumerate().filter(|(i, _)| *i != 1) {
assert_eq!(after.trkseg[i].rev_id, segment.rev_id, "{i}");
}
assert_eq!(fx.files[&id].trk.len(), file.trk.len());
}
#[test]
fn test_reversing_tracks_reverses_their_segments_in_place() {
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
let file = fx.files[&id].clone();
let (index, trk) = file
.trk
.iter()
.enumerate()
.find(|(_, t)| t.trkseg.len() >= 2)
.unwrap();
fx.selection = Selection::Track {
file_id: id,
trk_ids: HashSet::from([trk.id]),
};
Reverse.apply(&mut fx.state()).unwrap();
let after = &fx.files[&id];
// the track stays where it is, its segments are the opposite way
assert_eq!(after.trk[index].id, trk.id);
let path = |t: &crate::Track| -> Vec<f64> { t.trkseg.iter().flat_map(lngs).collect() };
let mut expected = path(trk);
expected.reverse();
assert_eq!(path(&after.trk[index]), expected);
// and the other tracks do not change
for (i, other) in file.trk.iter().enumerate().filter(|(i, _)| *i != index) {
assert_eq!(path(&after.trk[i]), path(other));
}
}
#[test]
fn test_times_are_mirrored() {
let (mut fx, id) = loaded("data/with_time.gpx");
let before = all_segments(&fx, id).remove(0);
Reverse.apply(&mut fx.state()).unwrap();
let after = all_segments(&fx, id).remove(0);
let (start, end) = (start_time(&before).unwrap(), end_time(&before).unwrap());
// the same period of time, the first point of the new path is the last one
assert_eq!(start_time(&after), Some(start));
assert_eq!(end_time(&after), Some(end));
let n = before.len();
for i in 0..n {
assert_eq!(
after[i].time.unwrap(),
start + end - before[n - 1 - i].time.unwrap()
);
assert_eq!(after[i].coordinates.lng, before[n - 1 - i].coordinates.lng);
}
}
#[test]
fn test_nothing_to_reverse() {
let (mut fx, id) = loaded("data/simple.gpx");
let before = fx.files[&id].clone();
fx.selection = Selection::Empty;
assert_eq!(
Reverse.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
fx.selection = Selection::Waypoints { file_id: id };
assert_eq!(
Reverse.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
assert!(Rc::ptr_eq(&fx.files[&id], &before));
// a single trackpoint has no direction
let mut file = (*fx.files[&id]).clone();
let segment = &mut file.trk[0].trkseg[0];
let len = segment.len();
segment.splice(1, len, vec![]);
fx.files.insert(id, Rc::new(file));
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
assert_eq!(
Reverse.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
}
@@ -0,0 +1,144 @@
use crate::{Apply, CommandError, State, round_trip, update_segments};
/// Makes each selected segment come back to where it started: the segments of the selected files
/// and tracks, and the selected segments, get a reversed copy of themselves after their last
/// trackpoint (see [`round_trip`]).
///
/// There is nothing to do for segments with fewer than two trackpoints, which have no way back.
#[derive(Debug)]
pub struct RoundTrip;
impl Apply for RoundTrip {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let locations: Vec<_> = state
.selection
.segment_locations(state.files, &state.order.0)
.into_iter()
.filter(|l| state.files[&l.file_id].trk[l.trk].trkseg[l.seg].len() >= 2)
.collect();
if locations.is_empty() {
return Err(CommandError::NothingToDo);
}
update_segments(state, &locations, |_, segment| round_trip(segment));
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use std::rc::Rc;
use crate::{
FileId, Load, Selection, TrackSegment, end_time, engine::command::fixture::Fixture,
};
use super::*;
fn loaded(path: &str) -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read(path).unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = fx.order.0[0];
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
(fx, id)
}
fn segments(fx: &Fixture, id: FileId) -> Vec<TrackSegment> {
fx.files[&id]
.trk
.iter()
.flat_map(|track| track.trkseg.iter().cloned())
.collect()
}
fn lngs(segment: &TrackSegment) -> Vec<f64> {
segment.iter().map(|p| p.coordinates.lng).collect()
}
#[test]
fn test_every_segment_of_a_file_comes_back() {
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
let before = segments(&fx, id);
RoundTrip.apply(&mut fx.state()).unwrap();
let after = segments(&fx, id);
assert_eq!(after.len(), before.len());
for (a, b) in after.iter().zip(&before) {
if b.len() < 2 {
assert_eq!(a.len(), b.len());
assert_eq!(a.rev_id, b.rev_id);
continue;
}
assert_eq!(a.id, b.id);
// the last trackpoint is not repeated
assert_eq!(a.len(), 2 * b.len() - 1);
let mut expected = lngs(b);
expected.extend(lngs(b).into_iter().rev().skip(1));
assert_eq!(lngs(a), expected);
}
}
#[test]
fn test_only_the_selected_segment_comes_back() {
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
let file = fx.files[&id].clone();
let trk = &file.trk[0];
assert!(trk.trkseg.len() >= 2);
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id: trk.id,
trkseg_ids: HashSet::from([trk.trkseg[1].id]),
};
RoundTrip.apply(&mut fx.state()).unwrap();
let after = &fx.files[&id].trk[0];
assert_eq!(after.trkseg[1].len(), 2 * trk.trkseg[1].len() - 1);
assert_eq!(after.trkseg[0].rev_id, trk.trkseg[0].rev_id);
}
#[test]
fn test_times_go_on_from_the_end() {
let (mut fx, id) = loaded("data/with_time.gpx");
let before = segments(&fx, id).remove(0);
RoundTrip.apply(&mut fx.state()).unwrap();
let after = segments(&fx, id).remove(0);
let n = before.len();
let end = end_time(&before).unwrap();
assert_eq!(after.len(), 2 * n - 1);
// the original part is untouched
for i in 0..n {
assert_eq!(after[i].time, before[i].time);
}
// the way back goes on from the end, and lasts as long as the way there
for i in 1..n {
assert_eq!(
after[n - 1 + i].time.unwrap(),
2 * end - before[n - 1 - i].time.unwrap()
);
}
assert_eq!(end_time(&after), Some(2 * end - before[0].time.unwrap()));
}
#[test]
fn test_nothing_to_do_without_a_way_back() {
let (mut fx, id) = loaded("data/simple.gpx");
let before = fx.files[&id].clone();
fx.selection = Selection::Empty;
assert_eq!(
RoundTrip.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
fx.selection = Selection::Waypoints { file_id: id };
assert_eq!(
RoundTrip.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
assert!(Rc::ptr_eq(&fx.files[&id], &before));
}
}
+810
View File
@@ -0,0 +1,810 @@
use std::rc::Rc;
use crate::{
Apply, Categories, CommandError, FileId, SegmentLocation, Selection, State, Statistics, Track,
TrackSegment, Trackpoint, distance, replace_trackpoints,
};
/// The values of an OSM attribute (surface, highway...) of the new trackpoints of a [`Route`], as
/// the intervals of consecutive trackpoints that share one: `starts[i]` is the index of the first
/// trackpoint of the interval `i`, and `values[i]` its value, 0 when unknown, else 1 + the index
/// of its name in `names`. No intervals means that the attribute is unknown everywhere.
#[derive(Debug, Default, Clone, Copy)]
pub struct RouteCategory<'a> {
pub starts: &'a [u32],
pub values: &'a [u8],
pub names: &'a [String],
}
impl RouteCategory<'_> {
/// The code in `categories` of the value of each of the `len` new trackpoints. A name is
/// added to the table of the categories if it is new.
fn codes(
&self,
categories: &mut Categories,
len: usize,
) -> Result<Vec<Option<u8>>, CommandError> {
if self.starts.len() != self.values.len() {
return Err(invalid("intervals of different lengths"));
}
let mut codes = vec![None; len];
for (i, (start, value)) in self.starts.iter().zip(self.values).enumerate() {
let end = self.starts.get(i + 1).map_or(len, |end| *end as usize);
let start = *start as usize;
if start > end || end > len {
return Err(invalid("intervals out of the trackpoints"));
}
if *value == 0 {
continue;
}
let name = self
.names
.get(usize::from(*value) - 1)
.ok_or_else(|| invalid("unknown name of category"))?;
codes[start..end].fill(categories.code(name));
}
Ok(codes)
}
}
/// Replaces the trackpoints `start..end` of a segment of the selection by the given ones (a pure
/// insertion when `start == end`, a pure removal when there are no new points). It is how the
/// routing tool reroutes a part of a segment, extends it, or removes the end of it.
///
/// `start` and `end` are indices in the trackpoints of the selection (see `RoutingBuffer`): the
/// range has to be in a single segment, and `start == end == the number of trackpoints` appends
/// to the last one. If the selection has no trackpoints at all, they go to its last segment, which
/// is created if needed (in the last track, or in a new one, of the last selected file).
///
/// The timestamps follow: the new points get some if the segment has, at the speed it had on the
/// replaced part (see [`replace_trackpoints`]).
///
/// The new points listed in `anchors` become anchors of the routing tool, shown at every zoom
/// level. The anchors of the segment out of the range are kept, and the ends of the segment are
/// anchors anyway. The ones right before and after the range, between which the route goes, become
/// shown at every zoom level as well.
#[derive(Debug)]
pub struct Route<'a> {
pub start: u32,
pub end: u32,
pub lng: &'a [f64],
pub lat: &'a [f64],
pub ele: &'a [f64],
pub surface: RouteCategory<'a>,
pub highway: RouteCategory<'a>,
pub sac_scale: RouteCategory<'a>,
pub mtb_scale: RouteCategory<'a>,
/// Indices among the new trackpoints.
pub anchors: &'a [u32],
}
fn invalid(message: &str) -> CommandError {
CommandError::InvalidData(message.into())
}
/// A segment of the selection, and the range of its trackpoints that is concerned.
pub(super) struct Target {
pub location: SegmentLocation,
pub start: usize,
pub end: usize,
}
impl Apply for Route<'_> {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let (start, end) = (self.start as usize, self.end as usize);
let len = self.lng.len();
if start > end || self.lat.len() != len || self.ele.len() != len {
return Err(invalid("invalid trackpoints range"));
}
if self.anchors.iter().any(|anchor| *anchor as usize >= len) {
return Err(invalid("anchor out of the new trackpoints"));
}
if start == end && len == 0 {
return Err(CommandError::NothingToDo);
}
let categories = &mut *state.categories;
let surface = self.surface.codes(&mut categories.surface, len)?;
let highway = self.highway.codes(&mut categories.highway, len)?;
let sac_scale = self.sac_scale.codes(&mut categories.sac_scale, len)?;
let mtb_scale = self.mtb_scale.codes(&mut categories.mtb_scale, len)?;
let points: Vec<Trackpoint> = (0..len)
.map(|i| Trackpoint {
coordinates: crate::LngLat {
lng: self.lng[i],
lat: self.lat[i],
},
ele: self.ele[i],
surface: surface[i],
highway: highway[i],
sac_scale: sac_scale[i],
mtb_scale: mtb_scale[i],
..Default::default()
})
.collect();
let mut points = points;
for anchor in self.anchors {
points[*anchor as usize].anchor = Some(0);
}
let mut file;
let target = match find_target(state, start, end)? {
Some(target) => {
file = (*state.files[&target.location.file_id]).clone();
target
}
None => {
let (file_id, trk) = container(state).ok_or(CommandError::NothingToDo)?;
if start != 0 || end != 0 {
return Err(invalid("trackpoints range out of bounds"));
}
file = (*state.files[&file_id]).clone();
// the segment of the last track, which may be new
let trk = match trk.or_else(|| file.trk.len().checked_sub(1)) {
Some(trk) => trk,
None => {
file.trk.push(Track::default());
file.trk.len() - 1
}
};
if file.trk[trk].trkseg.is_empty() {
file.trk[trk].trkseg.push(TrackSegment::default());
}
let seg = file.trk[trk].trkseg.len() - 1;
Target {
location: SegmentLocation { file_id, trk, seg },
start: 0,
end: 0,
}
}
};
let segment = &mut file.trk[target.location.trk].trkseg[target.location.seg];
let new_len = points.len();
let (speed, start_time) = speed_and_start_time(segment, &target, &points);
let has_times = !segment.is_empty() && segment[0].time.is_some();
if speed.is_some() || has_times {
// the timestamps of the whole segment can change
let mut all: Vec<Trackpoint> = segment.iter().cloned().collect();
let old_len = all.len();
replace_trackpoints(
&mut all,
target.start,
target.end,
points,
speed,
start_time,
false,
);
segment.splice(0, old_len, all);
} else {
segment.splice(target.start, target.end, points);
}
// the anchors next to the new points are the ones that were moved or removed around:
// they become permanent
for index in [target.start.checked_sub(1), Some(target.start + new_len)]
.into_iter()
.flatten()
{
if index < segment.len() && segment[index].anchor.is_some_and(|zoom| zoom != 0) {
segment.set_anchor(index, 0);
}
}
segment.rev_id = Default::default();
state.files.insert(target.location.file_id, Rc::new(file));
Ok(())
}
}
/// The segment of the trackpoints `start..end` of the selection.
pub(super) fn find_target(
state: &State,
start: usize,
end: usize,
) -> Result<Option<Target>, CommandError> {
let locations = state
.selection
.segment_locations(state.files, &state.order.0);
let segments: Vec<(SegmentLocation, usize)> = locations
.into_iter()
.map(|location| {
let segment = &state.files[&location.file_id].trk[location.trk].trkseg[location.seg];
(location, segment.len())
})
.collect();
let total: usize = segments.iter().map(|(_, len)| len).sum();
if total == 0 {
// an empty segment of the selection, if there is one
return match segments.last() {
Some((location, _)) if start == 0 && end == 0 => Ok(Some(Target {
location: *location,
start: 0,
end: 0,
})),
Some(_) => Err(invalid("trackpoints range out of bounds")),
None => Ok(None),
};
}
let mut offset = 0;
for (location, len) in segments.iter().filter(|(_, len)| *len > 0) {
// appending to the selection is appending to its last segment
let appends = offset + len == total && start == total;
if start >= offset && (start < offset + len || appends) {
if end > offset + len {
return Err(invalid("trackpoints range over several segments"));
}
return Ok(Some(Target {
location: *location,
start: start - offset,
end: end - offset,
}));
}
offset += len;
}
Err(invalid("trackpoints range out of bounds"))
}
/// The file, and the track (if the selection is one), where to create a segment in a selection
/// that has none.
fn container(state: &State) -> Option<(FileId, Option<usize>)> {
match &*state.selection {
Selection::File { file_ids } => {
let id = state
.order
.0
.iter()
.rev()
.find(|id| file_ids.contains(id))
.or_else(|| file_ids.iter().next())?;
state.files.contains_key(id).then_some((*id, None))
}
Selection::Track { file_id, trk_ids } => {
let trk = state
.files
.get(file_id)?
.trk
.iter()
.rposition(|trk| trk_ids.contains(&trk.id))?;
Some((*file_id, Some(trk)))
}
_ => None,
}
}
/// The speed at which the new points are reached and the time of the first one, when the segment
/// has times to keep consistent: the speed is the one that the segment had on the part that is
/// replaced, adjusted so that the moving speed of the whole segment stays what it was.
///
/// The replaced part goes from the trackpoint before the range (the anchor that stays) to the one
/// after it, or to the ends of the segment: the new points are the route between them.
///
/// This is what the routing tool computed from the statistics of the segment before.
fn speed_and_start_time(
segment: &TrackSegment,
target: &Target,
points: &[Trackpoint],
) -> (Option<f64>, Option<i64>) {
let time_at = |index: usize| {
(index < segment.len())
.then(|| segment[index].time)
.flatten()
};
let start_time = time_at(target.start);
if points.is_empty() || segment.is_empty() {
return (None, start_time);
}
let stats = Statistics::compute(segment);
let Some(moving_speed) = stats.global.moving_speed().filter(|speed| *speed > 0.0) else {
return (None, start_time);
};
// the statistics where the replaced part starts and ends
let first_index = target.start.saturating_sub(1);
let last_index = target.end.min(segment.len() - 1);
let (first, last) = (&stats.local[first_index], &stats.local[last_index]);
let moving_distance =
|stats: &crate::TrackpointStatistics| stats.moving_distance.unwrap_or(0.0);
let moving_time = |stats: &crate::TrackpointStatistics| stats.moving_time.unwrap_or(0);
let total_time = |stats: &crate::TrackpointStatistics| stats.total_time.unwrap_or(0);
let replacing_distance: f64 = points
.windows(2)
.map(|pair| distance(pair[0].coordinates, pair[1].coordinates))
.sum();
let replaced_distance = moving_distance(last) - moving_distance(first);
let global_moving_distance = stats.global.moving_distance.unwrap_or(0.0);
let new_distance = global_moving_distance + replacing_distance - replaced_distance;
// in milliseconds
let new_time = new_distance / moving_speed * 3_600_000.0;
let remaining_time = stats.global.moving_time.unwrap_or(0) as f64
- (moving_time(last) - moving_time(first)) as f64;
let mut replacing_time = new_time - remaining_time;
if replacing_time <= 0.0 {
// fall back to the time that the replaced part took
replacing_time = (total_time(last) - total_time(first)) as f64;
}
let speed = replacing_distance / (replacing_time / 3_600_000.0);
let start_time = start_time.or_else(|| {
// The first trackpoint has no time: the new points end when the last replaced one did,
// after the time they take and the time that the segment was not moving until then.
// (Suspicious: without a time for the last one either, it is "0", the year 1970.)
let end_time = time_at(last_index);
let stopped = (total_time(last) - moving_time(last)) as f64;
Some(end_time.unwrap_or(0) - (replacing_time + stopped) as i64)
});
(Some(speed), start_time)
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{File, Load, TrackSegmentId, engine::command::fixture::Fixture};
use super::*;
fn load(fx: &mut Fixture, path: &str) -> FileId {
let data = std::fs::read(path).unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
*fx.order.0.last().unwrap()
}
fn select_file(fx: &mut Fixture, id: FileId) {
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
}
/// The first segment of the file with segments, selected.
fn loaded() -> (Fixture, FileId) {
let mut fx = Fixture::default();
let id = load(&mut fx, "data/with_tracks_and_segments.gpx");
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 route<'a>(
start: u32,
end: u32,
lng: &'a [f64],
lat: &'a [f64],
ele: &'a [f64],
) -> Route<'a> {
Route {
start,
end,
lng,
lat,
ele,
surface: Default::default(),
highway: Default::default(),
sac_scale: Default::default(),
mtb_scale: Default::default(),
anchors: &[],
}
}
fn segment(fx: &Fixture, id: FileId, trk: usize, seg: usize) -> &TrackSegment {
&fx.files[&id].trk[trk].trkseg[seg]
}
#[test]
fn test_route_replaces_points_of_the_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);
route(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_route_append_and_remove() {
let (mut fx, id) = loaded();
let len = fx.files[&id].trk[0].trkseg[0].len() as u32;
route(len, len, &[1.0], &[2.0], &[3.0])
.apply(&mut fx.state())
.unwrap();
let seg = segment(&fx, id, 0, 0);
assert_eq!(seg.len(), len as usize + 1);
assert_eq!(seg[len as usize].ele, 3.0);
route(0, len + 1, &[], &[], &[])
.apply(&mut fx.state())
.unwrap();
assert_eq!(segment(&fx, id, 0, 0).len(), 0);
}
#[test]
fn test_route_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 = [
route(2, 1, &[], &[], &[]).apply(&mut fx.state()),
route(0, 0, &[1.0], &[], &[]).apply(&mut fx.state()),
route(0, len + 1, &[], &[], &[]).apply(&mut fx.state()),
route(len + 1, len + 1, &[1.0], &[1.0], &[1.0]).apply(&mut fx.state()),
Route {
anchors: &[1],
..route(0, 0, &[1.0], &[1.0], &[1.0])
}
.apply(&mut fx.state()),
];
assert!(
results
.iter()
.all(|r| matches!(r, Err(CommandError::InvalidData(_)))),
"{results:?}"
);
assert_eq!(
route(0, 0, &[], &[], &[]).apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
assert!(Rc::ptr_eq(&fx.files[&id], &before));
}
#[test]
fn test_route_needs_a_selection_with_a_file() {
let (mut fx, _) = loaded();
fx.selection = Selection::Empty;
assert_eq!(
route(0, 0, &[1.0], &[1.0], &[1.0]).apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
#[test]
fn test_route_indices_are_in_the_selection() {
let (mut fx, id) = loaded();
let file = fx.files[&id].clone();
select_file(&mut fx, id);
let lens: Vec<_> = file
.trk
.iter()
.flat_map(|trk| trk.trkseg.iter().map(|segment| segment.len()))
.collect();
assert!(lens.len() >= 2);
// inside the second segment
let second = lens[0] as u32;
route(second + 1, second + 2, &[1.0], &[2.0], &[3.0])
.apply(&mut fx.state())
.unwrap();
let (trk, seg) = (0, 1);
let segments_before: Vec<_> = file.trk.iter().flat_map(|t| &t.trkseg).collect();
let files = fx.files[&id].clone();
let segments_after: Vec<_> = files.trk.iter().flat_map(|t| &t.trkseg).collect();
for (i, (b, a)) in segments_before.iter().zip(&segments_after).enumerate() {
assert_eq!(a.rev_id != b.rev_id, i == 1, "segment {i}");
}
assert_eq!(segment(&fx, id, trk, seg)[1].coordinates.lng, 1.0);
// a range over two segments
let total: usize = lens.iter().sum();
assert!(matches!(
route(second - 1, second + 1, &[], &[], &[]).apply(&mut fx.state()),
Err(CommandError::InvalidData(_))
));
// after the last point: the last segment
let before = segments_after.last().unwrap().len();
let total = total as u32;
route(total, total, &[1.0], &[2.0], &[3.0])
.apply(&mut fx.state())
.unwrap();
let last = fx.files[&id].trk.last().unwrap().trkseg.last().unwrap();
assert_eq!(last.len(), before + 1);
}
#[test]
fn test_route_creates_the_segment_of_a_selection_that_has_none() {
let mut fx = Fixture::default();
let file = File::default();
let id = file.id;
fx.files.insert(id, Rc::new(file));
fx.order.0.push(id);
select_file(&mut fx, id);
route(0, 0, &[1.0, 2.0], &[1.0, 2.0], &[3.0, 4.0])
.apply(&mut fx.state())
.unwrap();
assert_eq!(fx.files[&id].trk.len(), 1);
assert_eq!(segment(&fx, id, 0, 0).len(), 2);
// the ends are anchors
assert_eq!(segment(&fx, id, 0, 0)[0].anchor, Some(0));
assert_eq!(segment(&fx, id, 0, 0)[1].anchor, Some(0));
// a track with no segments
let mut file = (*fx.files[&id]).clone();
file.trk[0].trkseg.clear();
let trk_id = file.trk[0].id;
fx.files.insert(id, Rc::new(file));
fx.selection = Selection::Track {
file_id: id,
trk_ids: HashSet::from([trk_id]),
};
route(0, 0, &[5.0], &[5.0], &[5.0])
.apply(&mut fx.state())
.unwrap();
assert_eq!(fx.files[&id].trk.len(), 1);
assert_eq!(segment(&fx, id, 0, 0).len(), 1);
// an empty segment is the target as well
let mut file = (*fx.files[&id]).clone();
file.trk[0].trkseg[0] = TrackSegment::default();
let seg_id: TrackSegmentId = file.trk[0].trkseg[0].id;
fx.files.insert(id, Rc::new(file));
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id,
trkseg_ids: HashSet::from([seg_id]),
};
route(0, 0, &[6.0], &[6.0], &[6.0])
.apply(&mut fx.state())
.unwrap();
assert_eq!(segment(&fx, id, 0, 0).len(), 1);
assert_eq!(fx.files[&id].trk[0].trkseg.len(), 1);
}
#[test]
fn test_route_anchors() {
let (mut fx, id) = loaded();
let len = segment(&fx, id, 0, 0).len();
assert!(len > 4);
let anchors_before: Vec<_> = segment(&fx, id, 0, 0).iter().map(|p| p.anchor).collect();
// 5 new points replacing the 2 in the middle, two of them are anchors
let (lng, lat, ele) = ([1.0; 5], [2.0; 5], [3.0; 5]);
Route {
anchors: &[1, 3],
..route(1, 3, &lng, &lat, &ele)
}
.apply(&mut fx.state())
.unwrap();
let anchors: Vec<_> = segment(&fx, id, 0, 0).iter().map(|p| p.anchor).collect();
assert_eq!(anchors.len(), len + 3);
assert_eq!(anchors[2], Some(0));
assert_eq!(anchors[4], Some(0));
assert_eq!(anchors[1], None);
assert_eq!(anchors[3], None);
assert_eq!(anchors[5], None);
// the others stay as they were, the ends are always anchors
assert_eq!(anchors[0], Some(0));
assert_eq!(anchors[len + 2], Some(0));
assert_eq!(anchors[len + 2 - 1], anchors_before[len - 2]);
}
#[test]
fn test_route_makes_the_anchors_around_the_range_permanent() {
let (mut fx, id) = loaded();
let mut file = (*fx.files[&id]).clone();
let len = file.trk[0].trkseg[0].len();
assert!(len > 8);
// anchors shown from different zoom levels, around and away from the range 4..6
for (index, zoom) in [(1, 7), (3, 9), (6, 12), (8, 14)] {
file.trk[0].trkseg[0].set_anchor(index, zoom);
}
fx.files.insert(id, Rc::new(file));
route(4, 6, &[1.0, 2.0], &[3.0, 4.0], &[5.0, 6.0])
.apply(&mut fx.state())
.unwrap();
let anchors: Vec<_> = segment(&fx, id, 0, 0).iter().map(|p| p.anchor).collect();
assert_eq!(anchors.len(), len);
// right before and after the new points: permanent
assert_eq!(anchors[3], Some(0));
assert_eq!(anchors[6], Some(0));
// the others are as they were
assert_eq!(anchors[1], Some(7));
assert_eq!(anchors[8], Some(14));
}
#[test]
fn test_route_categories() {
let (mut fx, id) = loaded();
let names = ["asphalt".to_string(), "gravel".to_string()];
let (lng, lat, ele) = ([1.0; 4], [2.0; 4], [3.0; 4]);
// asphalt, asphalt, unknown, gravel
Route {
surface: RouteCategory {
starts: &[0, 2, 3],
values: &[1, 0, 2],
names: &names,
},
highway: RouteCategory {
starts: &[0],
values: &[1],
names: &["track".to_string()],
},
..route(0, 0, &lng, &lat, &ele)
}
.apply(&mut fx.state())
.unwrap();
let file = fx.files[&id].clone();
let seg = &file.trk[0].trkseg[0];
let asphalt = fx.categories.surface.code("asphalt");
let gravel = fx.categories.surface.code("gravel");
let surfaces: Vec<_> = (0..4).map(|i| seg[i].surface).collect();
assert_eq!(surfaces, [asphalt, asphalt, None, gravel]);
let track = fx.categories.highway.code("track");
assert!((0..4).all(|i| seg[i].highway == track));
assert!((0..4).all(|i| seg[i].sac_scale.is_none() && seg[i].mtb_scale.is_none()));
// broken intervals or names
let broken = [
RouteCategory {
starts: &[0, 1],
values: &[1],
names: &names,
},
RouteCategory {
starts: &[5],
values: &[1],
names: &names,
},
RouteCategory {
starts: &[0],
values: &[3],
names: &names,
},
];
for surface in broken {
let result = Route {
surface,
..route(0, 0, &lng, &lat, &ele)
}
.apply(&mut fx.state());
assert!(matches!(result, Err(CommandError::InvalidData(_))));
}
}
/// A segment of `data/with_time.gpx`, selected: 80 trackpoints at 20 km/h.
fn timed() -> (Fixture, FileId) {
let mut fx = Fixture::default();
let id = load(&mut fx, "data/with_time.gpx");
select_file(&mut fx, id);
(fx, id)
}
fn times(seg: &TrackSegment) -> Vec<i64> {
seg.iter().map(|p| p.time.unwrap()).collect()
}
#[test]
fn test_route_gives_times_to_the_new_points() {
let (mut fx, id) = timed();
let seg = segment(&fx, id, 0, 0);
let (n, before) = (seg.len(), times(seg));
let duration = before[n - 1] - before[0];
// replace the points 20..40 by the straight line between them, with 5 points
let (a, b) = (seg[20].coordinates, seg[39].coordinates);
let at = |t: f64| (a.lng + t * (b.lng - a.lng), a.lat + t * (b.lat - a.lat));
let positions: Vec<_> = (0..5).map(|i| at(i as f64 / 4.0)).collect();
let lng: Vec<_> = positions.iter().map(|p| p.0).collect();
let lat: Vec<_> = positions.iter().map(|p| p.1).collect();
route(20, 40, &lng, &lat, &[0.0; 5])
.apply(&mut fx.state())
.unwrap();
let seg = segment(&fx, id, 0, 0);
assert_eq!(seg.len(), n - 15);
let after = times(seg);
assert!(after.windows(2).all(|w| w[0] < w[1]), "{after:?}");
// the points before are untouched
assert_eq!(after[..20], before[..20]);
// the speed of the replaced part is the one of the segment: the duration of the segment
// is about the same as on the same distance
let stats = Statistics::compute(seg);
let speed = stats.global.moving_speed().unwrap();
assert!((speed - 20.0).abs() < 1.0, "{speed}");
assert!(after[after.len() - 1] - after[0] <= duration);
}
#[test]
fn test_route_between_anchors_that_stay_keeps_them_untouched() {
let (mut fx, id) = timed();
let seg = segment(&fx, id, 0, 0);
let (n, before) = (seg.len(), times(seg));
let (a, b) = (seg[20].clone(), seg[39].clone());
// 5 points on the line between the anchors 20 and 39, which are not replaced
let at = |t: f64| {
(
a.coordinates.lng + t * (b.coordinates.lng - a.coordinates.lng),
a.coordinates.lat + t * (b.coordinates.lat - a.coordinates.lat),
)
};
let positions: Vec<_> = (1..=5).map(|i| at(i as f64 / 6.0)).collect();
let lng: Vec<_> = positions.iter().map(|p| p.0).collect();
let lat: Vec<_> = positions.iter().map(|p| p.1).collect();
route(21, 39, &lng, &lat, &[0.0; 5])
.apply(&mut fx.state())
.unwrap();
let seg = segment(&fx, id, 0, 0);
assert_eq!(seg.len(), n - 18 + 5);
let after = times(seg);
assert!(after.windows(2).all(|w| w[0] < w[1]), "{after:?}");
// the anchors keep their coordinates and times, and what is before them
assert_eq!(after[..=20], before[..=20]);
assert_eq!(seg[26].coordinates.lng, b.coordinates.lng);
assert_eq!(after[26], before[39]);
// and what comes after is unchanged as well
assert_eq!(after[27..], before[40..]);
let speed = Statistics::compute(seg).global.moving_speed().unwrap();
assert!((speed - 20.0).abs() < 1.0, "{speed}");
}
#[test]
fn test_route_appending_to_a_segment_with_times() {
let (mut fx, id) = timed();
let seg = segment(&fx, id, 0, 0);
let n = seg.len();
let last = seg[n - 1].clone();
// replace the last point and go 1 km further east
let lng = [last.coordinates.lng, last.coordinates.lng + 0.009];
let lat = [last.coordinates.lat; 2];
route(n as u32 - 1, n as u32, &lng, &lat, &[0.0; 2])
.apply(&mut fx.state())
.unwrap();
let seg = segment(&fx, id, 0, 0);
assert_eq!(seg.len(), n + 1);
// the replaced point is reached at the speed of the segment: about at its time
assert!((seg[n - 1].time.unwrap() - last.time.unwrap()).abs() < 1000);
let (a, b) = (seg[n - 1].time.unwrap(), seg[n].time.unwrap());
// the new point is reached at about 20 km/h
let km = distance(seg[n - 1].coordinates, seg[n].coordinates);
let seconds = (b - a) as f64 / 1000.0;
let expected = 3600.0 * km / 20.0;
assert!(
(seconds - expected).abs() < 0.1 * expected,
"{seconds} {expected}"
);
}
#[test]
fn test_route_does_not_invent_times() {
let mut fx = Fixture::default();
let id = load(&mut fx, "data/simple.gpx");
select_file(&mut fx, id);
assert!(segment(&fx, id, 0, 0).iter().all(|p| p.time.is_none()));
route(0, 2, &[1.0, 2.0], &[3.0, 4.0], &[5.0, 6.0])
.apply(&mut fx.state())
.unwrap();
assert!(segment(&fx, id, 0, 0).iter().all(|p| p.time.is_none()));
}
}
+340
View File
@@ -0,0 +1,340 @@
use std::rc::Rc;
use crate::{
Apply, CommandError, File, State, Track, TrackSegment, Waypoints, copy_segment, copy_track,
copy_waypoint,
};
use super::route::find_target;
/// What is cut in two where the trackpoint is.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SplitType {
/// The file becomes two files, which both have the waypoints (copies, in the second one).
Files,
/// The track becomes two tracks.
Tracks,
/// The segment becomes two segments.
Segments,
}
/// Cuts the file, the track or the segment of the trackpoint `at` of the selection (see
/// `RoutingBuffer`) in two, there: the trackpoint is the last one of the first part and the first
/// one of the second part.
///
/// The first part keeps the ids of what is cut, the second one has new ids, as the elements that
/// come after the trackpoint (the following segments of the track, the following tracks of the
/// file), which are moved to it. When the file is cut, the new file comes right after it. The
/// selection does not change.
#[derive(Debug)]
pub struct Split {
pub at: u32,
pub split_type: SplitType,
}
impl Apply for Split {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let at = self.at as usize;
let target = find_target(state, at, at)?.ok_or(CommandError::NothingToDo)?;
let location = target.location;
let (file_id, trk, seg, index) =
(location.file_id, location.trk, location.seg, target.start);
let mut file = (*state.files[&file_id]).clone();
if index >= file.trk[trk].trkseg[seg].len() {
return Err(CommandError::InvalidData(
"trackpoint out of the segment".into(),
));
}
let track = file.trk[trk].clone();
let segment = &track.trkseg[seg];
let (first, second) = (keep_up_to(segment, index), keep_from(segment, index));
// the track is cut: what is before the segment, and its first part, then the second part
// and what is after it
let split_track = || {
let mut first_track = track.clone();
first_track.trkseg.truncate(seg);
first_track.trkseg.push(first.clone());
let mut second_track = Track {
id: Default::default(),
trkseg: vec![second.clone()],
..track.clone()
};
second_track
.trkseg
.extend(track.trkseg[seg + 1..].iter().map(copy_segment));
(first_track, second_track)
};
match self.split_type {
SplitType::Segments => {
file.trk[trk].trkseg.splice(seg..=seg, [first, second]);
}
SplitType::Tracks => {
let (first_track, second_track) = split_track();
file.trk.splice(trk..=trk, [first_track, second_track]);
}
SplitType::Files => {
let (first_track, second_track) = split_track();
let mut second_file = File {
id: Default::default(),
trk: vec![second_track],
wpt: Waypoints::default(),
..file.clone()
};
second_file
.trk
.extend(file.trk[trk + 1..].iter().map(copy_track));
second_file
.wpt
.insert_at(0, file.wpt.iter().map(copy_waypoint).collect());
file.trk.truncate(trk);
file.trk.push(first_track);
let position = state.order.0.iter().position(|id| *id == file_id);
state.order.0.insert(
position.map_or(state.order.0.len(), |p| p + 1),
second_file.id,
);
state.files.insert(second_file.id, Rc::new(second_file));
}
}
state.files.insert(file_id, Rc::new(file));
Ok(())
}
}
/// The trackpoints of the segment up to `index` (included), in the same segment.
fn keep_up_to(segment: &TrackSegment, index: usize) -> TrackSegment {
let mut part = segment.clone();
let len = part.len();
part.splice(index + 1, len, vec![]);
part.rev_id = Default::default();
part
}
/// The trackpoints of the segment from `index` (included), in a new segment.
fn keep_from(segment: &TrackSegment, index: usize) -> TrackSegment {
let mut part = copy_segment(segment);
part.splice(0, index, vec![]);
part.rev_id = Default::default();
part
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{FileId, Load, Selection, TrackSegmentId, 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,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = fx.order.0[0];
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
(fx, id)
}
fn split(at: u32, split_type: SplitType) -> Split {
Split { at, split_type }
}
fn lngs(segment: &TrackSegment) -> Vec<f64> {
segment.iter().map(|p| p.coordinates.lng).collect()
}
fn path(file: &File) -> Vec<f64> {
file.trk
.iter()
.flat_map(|t| &t.trkseg)
.flat_map(lngs)
.collect()
}
/// The index in the selection of the trackpoint `local` of the segment `seg` of the track `trk`.
fn index(file: &File, trk: usize, seg: usize, local: usize) -> u32 {
let before: usize = file
.trk
.iter()
.enumerate()
.flat_map(|(t, track)| {
track
.trkseg
.iter()
.enumerate()
.map(move |(s, segment)| ((t, s), segment.len()))
})
.take_while(|((t, s), _)| (*t, *s) != (trk, seg))
.map(|(_, len)| len)
.sum();
(before + local) as u32
}
/// A segment with enough trackpoints, and its position.
fn long_segment(file: &File) -> (usize, usize) {
file.trk
.iter()
.enumerate()
.flat_map(|(t, track)| (0..track.trkseg.len()).map(move |s| (t, s)))
.find(|(t, s)| file.trk[*t].trkseg[*s].len() > 4)
.unwrap()
}
#[test]
fn test_split_segments() {
let (mut fx, id) = loaded();
let before = fx.files[&id].clone();
let (trk, seg) = long_segment(&before);
let original = &before.trk[trk].trkseg[seg];
let len = original.len();
split(index(&before, trk, seg, 2), SplitType::Segments)
.apply(&mut fx.state())
.unwrap();
let after = &fx.files[&id];
assert_eq!(after.trk.len(), before.trk.len());
let segments = &after.trk[trk].trkseg;
assert_eq!(segments.len(), before.trk[trk].trkseg.len() + 1);
let (a, b) = (&segments[seg], &segments[seg + 1]);
// the point is the end of the first part and the start of the second
assert_eq!(lngs(a), lngs(original)[..=2]);
assert_eq!(lngs(b), lngs(original)[2..]);
assert_eq!(a.len() + b.len(), len + 1);
// the first part keeps the id, the second one is new
assert_eq!(a.id, original.id);
assert_ne!(b.id, original.id);
assert_ne!(a.rev_id, original.rev_id);
// the ends are anchors
assert_eq!(a[a.len() - 1].anchor, Some(0));
assert_eq!(b[0].anchor, Some(0));
// the rest of the file is untouched
for (i, segment) in before.trk[trk].trkseg.iter().enumerate() {
if i != seg {
let moved = if i > seg { i + 1 } else { i };
assert_eq!(segments[moved].rev_id, segment.rev_id);
}
}
}
#[test]
fn test_split_tracks() {
let (mut fx, id) = loaded();
let before = fx.files[&id].clone();
let (trk, seg) = long_segment(&before);
split(index(&before, trk, seg, 3), SplitType::Tracks)
.apply(&mut fx.state())
.unwrap();
let after = &fx.files[&id];
assert_eq!(after.trk.len(), before.trk.len() + 1);
let (a, b) = (&after.trk[trk], &after.trk[trk + 1]);
assert_eq!(a.id, before.trk[trk].id);
assert_ne!(b.id, a.id);
// the segments before are in the first track, the ones after in the second one
assert_eq!(a.trkseg.len(), seg + 1);
assert_eq!(b.trkseg.len(), before.trk[trk].trkseg.len() - seg);
// the segments before are the same ones, with the same trackpoints
for (kept, original) in a.trkseg[..seg].iter().zip(&before.trk[trk].trkseg[..seg]) {
assert_eq!((kept.id, kept.rev_id), (original.id, original.rev_id));
let chunks = |segment: &TrackSegment| -> Vec<_> {
segment.chunks().iter().map(|chunk| chunk.id).collect()
};
assert_eq!(chunks(kept), chunks(original));
}
// new ids for everything that moved, so that they are unique
let ids: HashSet<TrackSegmentId> = after
.trk
.iter()
.flat_map(|t| t.trkseg.iter().map(|s| s.id))
.collect();
assert_eq!(
ids.len(),
after.trk.iter().map(|t| t.trkseg.len()).sum::<usize>()
);
// the path goes on, with the point twice
let mut expected = path(&before);
let at = index(&before, trk, seg, 3) as usize;
expected.insert(at, expected[at]);
assert_eq!(path(after), expected);
// the info of the track is the same
assert_eq!(a.info, b.info);
}
#[test]
fn test_split_files() {
let (mut fx, id) = loaded();
let mut file = (*fx.files[&id]).clone();
let wpt = crate::Waypoint {
name: Some("w".into()),
..Default::default()
};
file.wpt.insert_at(0, vec![wpt.clone()]);
fx.files.insert(id, Rc::new(file));
let before = fx.files[&id].clone();
let (trk, seg) = long_segment(&before);
let at = index(&before, trk, seg, 1);
split(at, SplitType::Files).apply(&mut fx.state()).unwrap();
assert_eq!(fx.files.len(), 2);
let second_id = fx.order.0[1];
assert_eq!(fx.order.0[0], id);
assert_ne!(second_id, id);
let (a, b) = (&fx.files[&id], &fx.files[&second_id]);
// the first file stops at the point, the second one starts there
let all = path(&before);
assert_eq!(path(a), all[..=at as usize]);
assert_eq!(path(b), all[at as usize..]);
assert_eq!(a.info, b.info);
// each has the waypoints, the copy has its own ids
assert_eq!(a.wpt.len(), 1);
assert_eq!(b.wpt.len(), 1);
assert_ne!(a.wpt[0].id, b.wpt[0].id);
assert_eq!(b.wpt[0].name, wpt.name);
// nothing is shared by id between the two
let ids = |f: &File| -> HashSet<_> {
f.trk
.iter()
.flat_map(|t| std::iter::once(t.id.0).chain(t.trkseg.iter().map(|s| s.id.0)))
.collect()
};
assert!(ids(a).is_disjoint(&ids(b)));
// the selection is the same
assert!(matches!(&fx.selection, Selection::File { file_ids } if file_ids.contains(&id)));
}
#[test]
fn test_invalid_or_nothing() {
let (mut fx, id) = loaded();
let before = fx.files[&id].clone();
let total = path(&before).len() as u32;
assert!(matches!(
split(total + 5, SplitType::Segments).apply(&mut fx.state()),
Err(CommandError::InvalidData(_))
));
// after the last trackpoint: appending, not a trackpoint
assert!(matches!(
split(total, SplitType::Segments).apply(&mut fx.state()),
Err(CommandError::InvalidData(_))
));
fx.selection = Selection::Empty;
assert_eq!(
split(0, SplitType::Files).apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
assert!(Rc::ptr_eq(&fx.files[&id], &before));
}
}
+193
View File
@@ -0,0 +1,193 @@
use crate::{
Apply, CommandError, State, TrackSegment, artificial_segment_times, changed_segment_times,
update_segments,
};
/// How the timestamps of the selection are set.
#[derive(Debug, Clone, Copy)]
pub enum TimeKind {
/// Starts at `Time::start_time`. The trackpoints that have timestamps keep their durations
/// multiplied by `ratio`, and the ones that have none follow the previous one at `speed`
/// (km/h).
Change { speed: f64, ratio: f64 },
/// Makes timestamps up for the whole selection, which lasts `total_time` seconds: the longer
/// and the steeper an interval, the more time it takes.
Artificial { total_time: f64 },
}
/// Sets the timestamps of the selected segments, one after the other: each one goes on from the
/// end of the previous one, the first one starts at `start_time` (in milliseconds).
#[derive(Debug)]
pub struct Time {
pub start_time: i64,
pub kind: TimeKind,
}
impl Apply for Time {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
match self.kind {
TimeKind::Change { speed, ratio } => {
if !(speed.is_finite() && speed > 0.0 && ratio.is_finite() && ratio > 0.0) {
return Err(CommandError::InvalidData(format!(
"speed {speed} and ratio {ratio} should be positive"
)));
}
}
TimeKind::Artificial { total_time } => {
if !(total_time.is_finite() && total_time >= 0.0) {
return Err(CommandError::InvalidData(format!(
"total time {total_time} should not be negative"
)));
}
}
}
let locations = state
.selection
.segment_locations(state.files, &state.order.0);
let segments: Vec<&TrackSegment> = locations
.iter()
.map(|l| &state.files[&l.file_id].trk[l.trk].trkseg[l.seg])
.collect();
if segments.iter().all(|segment| segment.is_empty()) {
return Err(CommandError::NothingToDo);
}
let times = match self.kind {
TimeKind::Change { speed, ratio } => {
changed_segment_times(&segments, self.start_time, speed, ratio)
}
TimeKind::Artificial { total_time } => {
artificial_segment_times(&segments, self.start_time, total_time)
}
};
update_segments(state, &locations, |i, segment| {
let times = &times[i];
segment.update_all(|j, pt| pt.time = times.get(j).copied().flatten());
});
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{FileId, Load, Selection, engine::command::fixture::Fixture};
use super::*;
fn loaded(path: &str) -> (Fixture, FileId) {
let mut fx = Fixture::default();
let data = std::fs::read(path).unwrap();
Load {
data: &data,
name: "file",
}
.apply(&mut fx.state())
.unwrap();
let id = fx.order.0[0];
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
(fx, id)
}
fn all_times(fx: &Fixture, id: FileId) -> Vec<Option<i64>> {
fx.files[&id]
.trk
.iter()
.flat_map(|trk| {
trk.trkseg
.iter()
.flat_map(|seg| seg.iter().map(|pt| pt.time))
})
.collect()
}
#[test]
fn test_artificial_timestamps_last_the_total_time() {
let (mut fx, id) = loaded("data/with_tracks_and_segments.gpx");
Time {
start_time: 1_000_000,
kind: TimeKind::Artificial { total_time: 3600.0 },
}
.apply(&mut fx.state())
.unwrap();
let times: Vec<i64> = all_times(&fx, id).into_iter().map(Option::unwrap).collect();
assert_eq!(times[0], 1_000_000);
assert!(times.windows(2).all(|w| w[0] <= w[1]));
assert!((times.last().unwrap() - 1_000_000 - 3_600_000).abs() <= times.len() as i64);
}
#[test]
fn test_change_starts_at_the_start_time_and_follows_the_speed() {
let (mut fx, id) = loaded("data/simple.gpx");
Time {
start_time: 5_000,
kind: TimeKind::Change {
speed: 10.0,
ratio: 1.0,
},
}
.apply(&mut fx.state())
.unwrap();
let times: Vec<i64> = all_times(&fx, id).into_iter().map(Option::unwrap).collect();
assert_eq!(times[0], 5_000);
assert!(times.windows(2).all(|w| w[0] <= w[1]));
assert!(times.last() > times.first());
}
#[test]
fn test_change_shifts_existing_timestamps_and_scales_them() {
let (mut fx, id) = loaded("data/with_time.gpx");
let before: Vec<i64> = all_times(&fx, id).into_iter().map(Option::unwrap).collect();
Time {
start_time: 0,
kind: TimeKind::Change {
speed: 10.0,
ratio: 2.0,
},
}
.apply(&mut fx.state())
.unwrap();
let after: Vec<i64> = all_times(&fx, id).into_iter().map(Option::unwrap).collect();
assert_eq!(after[0], 0);
let (d_before, d_after) = (before[1] - before[0], after[1] - after[0]);
assert!((d_after - 2 * d_before).abs() <= 1);
}
#[test]
fn test_invalid_input_and_empty_selection() {
let (mut fx, id) = loaded("data/simple.gpx");
let before = all_times(&fx, id);
assert!(matches!(
Time {
start_time: 0,
kind: TimeKind::Change {
speed: 0.0,
ratio: 1.0
}
}
.apply(&mut fx.state()),
Err(CommandError::InvalidData(_))
));
assert_eq!(all_times(&fx, id), before);
fx.selection = Selection::Empty;
assert!(matches!(
Time {
start_time: 0,
kind: TimeKind::Artificial { total_time: 10.0 }
}
.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
));
}
}