From 290f4d8c44a8ab34c14efbeb966337e84976f292 Mon Sep 17 00:00:00 2001 From: vcoppe Date: Wed, 7 Oct 2026 18:45:56 +0200 Subject: [PATCH] reverse --- gpx-rs/engine/src/core/algorithm/reverse.rs | 38 +++++ gpx-rs/engine/src/engine/command/command.rs | 4 +- gpx-rs/engine/src/engine/command/tools/mod.rs | 2 + .../src/engine/command/tools/round_trip.rs | 153 ++++++++++++++++++ gpx-rs/wasm/src/lib.rs | 7 + .../toolbar/tools/routing/Routing.svelte | 2 +- website/src/lib/engine.ts | 9 ++ website/src/lib/logic/file-actions.ts | 26 --- 8 files changed, 213 insertions(+), 28 deletions(-) create mode 100644 gpx-rs/engine/src/engine/command/tools/round_trip.rs diff --git a/gpx-rs/engine/src/core/algorithm/reverse.rs b/gpx-rs/engine/src/core/algorithm/reverse.rs index cc8412cd6..a0dad8d8f 100644 --- a/gpx-rs/engine/src/core/algorithm/reverse.rs +++ b/gpx-rs/engine/src/core/algorithm/reverse.rs @@ -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 diff --git a/gpx-rs/engine/src/engine/command/command.rs b/gpx-rs/engine/src/engine/command/command.rs index 03c0e8bc9..2cc48a779 100644 --- a/gpx-rs/engine/src/engine/command/command.rs +++ b/gpx-rs/engine/src/engine/command/command.rs @@ -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), diff --git a/gpx-rs/engine/src/engine/command/tools/mod.rs b/gpx-rs/engine/src/engine/command/tools/mod.rs index b9256ab3e..b1aeb8bba 100644 --- a/gpx-rs/engine/src/engine/command/tools/mod.rs +++ b/gpx-rs/engine/src/engine/command/tools/mod.rs @@ -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::*; diff --git a/gpx-rs/engine/src/engine/command/tools/round_trip.rs b/gpx-rs/engine/src/engine/command/tools/round_trip.rs new file mode 100644 index 000000000..98548633b --- /dev/null +++ b/gpx-rs/engine/src/engine/command/tools/round_trip.rs @@ -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 = 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 { + fx.files[&id] + .trk + .iter() + .flat_map(|track| track.trkseg.iter().cloned()) + .collect() + } + + fn lngs(segment: &TrackSegment) -> Vec { + 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)); + } +} diff --git a/gpx-rs/wasm/src/lib.rs b/gpx-rs/wasm/src/lib.rs index ba867b12b..d0e08439c 100644 --- a/gpx-rs/wasm/src/lib.rs +++ b/gpx-rs/wasm/src/lib.rs @@ -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 diff --git a/website/src/lib/components/toolbar/tools/routing/Routing.svelte b/website/src/lib/components/toolbar/tools/routing/Routing.svelte index 61be3b0b8..24c3577f3 100644 --- a/website/src/lib/components/toolbar/tools/routing/Routing.svelte +++ b/website/src/lib/components/toolbar/tools/routing/Routing.svelte @@ -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()} > {i18n._('toolbar.routing.round_trip.button')} diff --git a/website/src/lib/engine.ts b/website/src/lib/engine.ts index 7bf501a1b..e6674a177 100644 --- a/website/src/lib/engine.ts +++ b/website/src/lib/engine.ts @@ -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)); } diff --git a/website/src/lib/logic/file-actions.ts b/website/src/lib/logic/file-actions.ts index 2e9ebe398..328a56c1b 100644 --- a/website/src/lib/logic/file-actions.ts +++ b/website/src/lib/logic/file-actions.ts @@ -101,32 +101,6 @@ export async function loadFiles(list: FileList | File[]): Promise { // 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;