mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-27 02:27:18 +00:00
progress
This commit is contained in:
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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 = ×[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)
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user