This commit is contained in:
vcoppe committed 2026-10-07 18:45:56 +02:00
1 parent a48608840f
commit 290f4d8c44
8 files changed
+213 -28

No files matched your search

@@ -61,6 +61,21 @@ pub fn reverse_segment(
segment.rev_id = Default::default();
}
/// Makes a segment go back where it started: a reversed copy of it is added after its last
/// trackpoint, without the first trackpoint of the copy, which is the last one of the segment.
///
/// The timestamps of the copy go on from the end of the segment: it would start at the time of the
/// last trackpoint, and it lasts as long as the segment. Only the end of the segment changes, so
/// its other chunks stay shared.
pub fn round_trip(segment: &mut TrackSegment) {
let len = segment.len();
let end = end_time(segment);
let mut back = segment.clone();
reverse_segment(&mut back, end, end);
segment.splice(len, len, back.iter().skip(1).cloned().collect());
segment.rev_id = Default::default();
}
/// Reverses the segments of a track, and their trackpoints: see [`reverse_segment`] for the
/// meaning of the times, which are the ones of the track as a whole.
pub fn reverse_segments(
@@ -149,6 +164,29 @@ mod tests {
assert_eq!(times(&s), [None, None, None]);
}
#[test]
fn test_round_trip_goes_back_in_time_from_the_end() {
let mut s = segment(&[(0.0, Some(0)), (1.0, Some(10)), (2.0, Some(40))]);
let first = s.rev_id;
round_trip(&mut s);
assert_ne!(s.rev_id, first);
// the last trackpoint is the turning point, it is not repeated
assert_eq!(lngs(&s), [0.0, 1.0, 2.0, 1.0, 0.0]);
// the way back lasts as long as the way there, and starts when it ended
assert_eq!(times(&s), [Some(0), Some(10), Some(40), Some(70), Some(80)]);
// and it is a segment whose ends are anchors
assert_eq!(s[0].anchor, Some(0));
assert_eq!(s[4].anchor, Some(0));
}
#[test]
fn test_round_trip_without_times() {
let mut s = segment(&[(0.0, None), (1.0, None)]);
round_trip(&mut s);
assert_eq!(lngs(&s), [0.0, 1.0, 0.0]);
assert_eq!(times(&s), [None; 3]);
}
#[test]
fn test_segments_keep_the_gaps_between_them() {
// 0..20, then 100..120: a gap of 80
+3 -1
View File
@@ -2,7 +2,7 @@ use crate::{Apply, CommandError, State};
use crate::{
ChangeLoopStart, Clean, Crop, Delete, DeleteAll, DeleteWaypoint, Duplicate, EditWaypoint,
Elevation, Extract, InsertAnchor, Load, LoadFiles, Merge, Metadata, Move, MoveWaypoint, New,
NewTrack, NewTrackSegment, NewWaypoint, Paste, Reverse, Route, Split, Style, Time,
NewTrack, NewTrackSegment, NewWaypoint, Paste, Reverse, RoundTrip, Route, Split, Style, Time,
};
/// A user action that edits the files. Each variant wraps the command's own struct, whose
@@ -26,6 +26,7 @@ pub enum Command<'a> {
NewTrack(NewTrack),
NewTrackSegment(NewTrackSegment),
Reverse(Reverse),
RoundTrip(RoundTrip),
Route(Route<'a>),
NewWaypoint(NewWaypoint<'a>),
EditWaypoint(EditWaypoint<'a>),
@@ -58,6 +59,7 @@ impl Apply for Command<'_> {
Command::NewTrack(c) => c.apply(state),
Command::NewTrackSegment(c) => c.apply(state),
Command::Reverse(c) => c.apply(state),
Command::RoundTrip(c) => c.apply(state),
Command::Route(c) => c.apply(state),
Command::NewWaypoint(c) => c.apply(state),
Command::EditWaypoint(c) => c.apply(state),
@@ -9,6 +9,7 @@ mod merge;
mod move_waypoint;
mod new_waypoint;
mod reverse;
mod round_trip;
mod route;
mod split;
mod time;
@@ -24,6 +25,7 @@ pub use merge::*;
pub use move_waypoint::*;
pub use new_waypoint::*;
pub use reverse::*;
pub use round_trip::*;
pub use route::*;
pub use split::*;
pub use time::*;
@@ -0,0 +1,153 @@
use std::{collections::HashMap, rc::Rc};
use crate::{Apply, CommandError, File, FileId, State, round_trip};
/// 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 mut files: HashMap<FileId, File> = HashMap::new();
for location in state
.selection
.segment_locations(state.files, &state.order.0)
{
let (file_id, trk, seg) = (location.file_id, location.trk, location.seg);
if state.files[&file_id].trk[trk].trkseg[seg].len() < 2 {
continue;
}
let file = files
.entry(file_id)
.or_insert_with(|| (*state.files[&file_id]).clone());
round_trip(&mut file.trk[trk].trkseg[seg]);
}
if files.is_empty() {
return Err(CommandError::NothingToDo);
}
for (file_id, file) in files {
state.files.insert(file_id, Rc::new(file));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{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));
}
}
+7
View File
@@ -375,6 +375,13 @@ pub fn reverse() -> bool {
edit(Command::Reverse(engine::Reverse))
}
/// Adds to each selected segment a reversed copy of itself (without repeating its last point), so that it goes back to where it
/// started.
#[wasm_bindgen]
pub fn round_trip() -> bool {
edit(Command::RoundTrip(engine::RoundTrip))
}
// Routing
//
// The indices of the trackpoints and of the anchors are the ones of the routing buffers: they only
@@ -180,7 +180,7 @@
variant="outline"
class="gap-1 text-xs px-1.5 py-1.5 h-fit"
disabled={!validSelection}
onclick={fileActions.createRoundTripForSelection}
onclick={() => engine.roundTrip()}
>
<Repeat class="size-3" />{i18n._('toolbar.routing.round_trip.button')}
</ButtonWithTooltip>
+9
View File
@@ -645,6 +645,15 @@ class Engine {
return this.run((w) => w.reverse());
}
/**
* Makes each selected segment (or the segments of the selected files and tracks) come back to
* where it started: a reversed copy of it is added after its last trackpoint (which is not
* repeated), and goes on in time. Resolves to `false` when no segment has the two trackpoints that a way back needs.
*/
roundTrip() {
return this.run((w) => w.round_trip());
}
metadata(name: string, desc: string) {
return this.run((w) => w.metadata(name, desc));
}
-26
View File
@@ -101,32 +101,6 @@ export async function loadFiles(list: FileList | File[]): Promise<string[]> {
// Helper functions for file operations
export const fileActions = {
createRoundTripForSelection() {
if (!get(selection).hasAnyChildren(new ListRootItem(), true, ['waypoints'])) {
return;
}
fileActionManager.applyGlobal((draft) => {
selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => {
let file = draft.get(fileId);
if (file) {
if (level === ListLevel.FILE) {
file.roundTrip();
} else if (level === ListLevel.TRACK) {
for (let item of items) {
let trackIndex = (item as ListTrackItem).getTrackIndex();
file.roundTripTrack(trackIndex);
}
} else if (level === ListLevel.SEGMENT) {
for (let item of items) {
let trackIndex = (item as ListTrackSegmentItem).getTrackIndex();
let segmentIndex = (item as ListTrackSegmentItem).getSegmentIndex();
file.roundTripTrackSegment(trackIndex, segmentIndex);
}
}
}
});
});
},
mergeSelection: (mergeTraces: boolean, removeGaps: boolean) => {
fileActionManager.applyGlobal((draft) => {
let first = true;