From b6c0f5190a166979bbb8b0217be56a6a898cd0c3 Mon Sep 17 00:00:00 2001 From: vcoppe Date: Mon, 26 Oct 2026 18:51:56 +0100 Subject: [PATCH] route full support --- gpx-rs/src/api/commands.rs | 23 +- gpx-rs/src/api/dto.rs | 90 ++- gpx-rs/src/api/queries.rs | 4 +- gpx-rs/src/core/algorithm/reverse.rs | 15 +- gpx-rs/src/core/gpx/file.rs | 52 +- gpx-rs/src/core/gpx/mod.rs | 2 + gpx-rs/src/core/gpx/route.rs | 15 + gpx-rs/src/core/io/parse.rs | 87 ++- gpx-rs/src/core/io/write.rs | 233 ++++--- gpx-rs/src/engine/command/edit/metadata.rs | 28 +- gpx-rs/src/engine/command/edit/mod.rs | 2 + gpx-rs/src/engine/command/edit/new_route.rs | 54 ++ gpx-rs/src/engine/command/edit/new_track.rs | 43 +- gpx-rs/src/engine/command/edit/style.rs | 22 +- gpx-rs/src/engine/command/file/delete.rs | 17 +- gpx-rs/src/engine/command/file/duplicate.rs | 28 +- .../src/engine/command/file/move_elements.rs | 12 +- gpx-rs/src/engine/command/file/paste.rs | 51 +- gpx-rs/src/engine/command/file/transfer.rs | 161 ++++- gpx-rs/src/engine/command/mod.rs | 8 +- gpx-rs/src/engine/command/pattern/copy.rs | 14 +- .../engine/command/pattern/update_segments.rs | 10 +- .../engine/command/pattern/update_selected.rs | 29 +- gpx-rs/src/engine/command/routes_tests.rs | 604 ++++++++++++++++++ .../engine/command/tools/change_loop_start.rs | 2 +- gpx-rs/src/engine/command/tools/clean.rs | 41 +- gpx-rs/src/engine/command/tools/crop.rs | 17 +- gpx-rs/src/engine/command/tools/elevation.rs | 4 +- gpx-rs/src/engine/command/tools/extract.rs | 118 ++-- .../src/engine/command/tools/insert_anchor.rs | 4 +- gpx-rs/src/engine/command/tools/merge.rs | 78 ++- .../src/engine/command/tools/new_waypoint.rs | 3 +- gpx-rs/src/engine/command/tools/reduce.rs | 2 +- gpx-rs/src/engine/command/tools/reverse.rs | 30 +- gpx-rs/src/engine/command/tools/round_trip.rs | 2 +- gpx-rs/src/engine/command/tools/route.rs | 31 +- gpx-rs/src/engine/command/tools/split.rs | 198 +++--- gpx-rs/src/engine/command/tools/time.rs | 6 +- .../src/engine/derived/coordinates_cache.rs | 4 +- gpx-rs/src/engine/derived/file_structure.rs | 81 ++- gpx-rs/src/engine/derived/routing_buffer.rs | 2 +- gpx-rs/src/engine/derived/statistics_cache.rs | 29 +- gpx-rs/src/engine/mod.rs | 12 +- gpx-rs/src/engine/state/clipboard.rs | 57 +- gpx-rs/src/engine/state/selection.rs | 108 +++- gpx-rs/src/storage/codec/mod.rs | 124 ++-- gpx-rs/src/storage/codec/v1.rs | 39 +- gpx-rs/src/storage/persister.rs | 3 +- tauri/src/commands/disk.rs | 2 - tauri/src/commands/edit.rs | 5 + tauri/src/commands/queries.rs | 2 - tauri/src/commands/selection.rs | 10 + tauri/src/lib.rs | 2 + tauri/src/tests.rs | 2 +- tauri/src/workspace.rs | 1 + wasm/src/command/edit.rs | 5 + wasm/src/queries/files.rs | 6 +- wasm/src/selection.rs | 8 + wasm/src/tests.rs | 24 +- wasm/src/ts.rs | 23 +- wasm/src/types.rs | 2 + website/src/lib/components/Menu.svelte | 13 +- .../src/lib/components/export/Export.svelte | 13 - .../components/file-list/FileListNode.svelte | 14 +- .../file-list/FileListNodeContent.svelte | 34 +- .../file-list/FileListNodeLabel.svelte | 31 +- .../file-list/metadata/MetadataDialog.svelte | 13 +- .../file-list/sortable-file-list.ts | 26 +- .../file-list/style/StyleDialog.svelte | 20 +- .../lib/components/map/gpx-layer/gpx-layer.ts | 21 +- .../components/toolbar/tools/Extract.svelte | 9 +- .../lib/components/toolbar/tools/Merge.svelte | 7 +- .../lib/components/toolbar/tools/Time.svelte | 3 +- .../toolbar/tools/reduce/Reduce.svelte | 5 +- .../toolbar/tools/routing/Routing.svelte | 7 +- .../toolbar/tools/routing/routing-controls.ts | 3 +- .../toolbar/tools/scissors/Scissors.svelte | 26 +- .../toolbar/tools/scissors/scissors.ts | 57 +- .../toolbar/tools/scissors/split-controls.ts | 23 +- website/src/lib/engine/backend/tauri.ts | 6 +- website/src/lib/engine/backend/types.ts | 5 +- website/src/lib/engine/backend/wasm.ts | 11 +- website/src/lib/engine/engine.test.ts | 63 +- website/src/lib/engine/engine.ts | 16 +- website/src/lib/engine/file-state.ts | 77 ++- .../src/lib/engine/file-visibility.test.ts | 24 + website/src/lib/engine/file-visibility.ts | 21 +- .../src/lib/engine/selection-helpers.test.ts | 41 +- website/src/lib/engine/selection-helpers.ts | 54 +- website/src/lib/engine/types.ts | 32 +- website/src/locales/en.json | 4 +- 91 files changed, 2523 insertions(+), 847 deletions(-) create mode 100644 gpx-rs/src/core/gpx/route.rs create mode 100644 gpx-rs/src/engine/command/edit/new_route.rs create mode 100644 gpx-rs/src/engine/command/routes_tests.rs diff --git a/gpx-rs/src/api/commands.rs b/gpx-rs/src/api/commands.rs index 9e3886931..c55081b4b 100644 --- a/gpx-rs/src/api/commands.rs +++ b/gpx-rs/src/api/commands.rs @@ -49,7 +49,7 @@ pub fn select_elements( } /// Selects all the elements of the same kind as the selected ones, in the same place: all the -/// files, the tracks of the file, the segments of the track, the waypoints of the file. All the +/// files, the routes or the tracks of the file, the segments of the track, the waypoints of the file. All the /// files when nothing is selected. pub fn select_all(engine: &mut Engine) -> Outcome { execute(engine, Action::SelectAll) @@ -62,6 +62,19 @@ pub fn arrow_select(engine: &mut Engine, down: bool, add: bool) - execute(engine, Action::ArrowSelect { down, add }) } +pub fn select_routes( + engine: &mut Engine, + file_id: Uuid, + route_ids: &[Uuid], + mode: engine::SelectMode, +) -> Outcome { + let selection = engine::Selection::Route { + file_id: engine::FileId(file_id), + rte_ids: route_ids.iter().copied().map(engine::RouteId).collect(), + }; + select_elements(engine, selection, mode) +} + pub fn select_tracks( engine: &mut Engine, file_id: Uuid, @@ -304,6 +317,10 @@ pub fn style( ) } +pub fn new_route(engine: &mut Engine) -> Outcome { + edit(engine, Command::NewRoute(engine::NewRoute)) +} + pub fn new_track(engine: &mut Engine) -> Outcome { edit(engine, Command::NewTrack(engine::NewTrack)) } @@ -575,7 +592,7 @@ pub fn route( at_revision(engine, revision, |engine| { edit( engine, - Command::Route(engine::Route { + Command::Reroute(engine::Reroute { start, end, lng, @@ -618,7 +635,7 @@ pub fn change_loop_start(engine: &mut Engine, revision: u32, inde }) } -/// Cuts the file, the track or the segment (see `SplitType`) of the trackpoint `at` of the +/// Cuts the file, the route, the track or the segment (see `SplitType`) of the trackpoint `at` of the /// selection in two, there: the trackpoint ends the first part and starts the second one. A file that /// is cut is kept (and its two parts are new files) if the history needs it, see /// [`History::KEEPS_ORIGINALS`]. diff --git a/gpx-rs/src/api/dto.rs b/gpx-rs/src/api/dto.rs index 8700a2586..af7224d3f 100644 --- a/gpx-rs/src/api/dto.rs +++ b/gpx-rs/src/api/dto.rs @@ -131,6 +131,10 @@ pub enum Selection { File { file_ids: Vec, }, + Route { + file_id: Uuid, + route_ids: Vec, + }, Track { file_id: Uuid, track_ids: Vec, @@ -157,6 +161,10 @@ impl From<&engine::Selection> for Selection { S::File { file_ids } => Self::File { file_ids: file_ids.iter().map(|id| id.0).collect(), }, + S::Route { file_id, rte_ids } => Self::Route { + file_id: file_id.0, + route_ids: rte_ids.iter().map(|id| id.0).collect(), + }, S::Track { file_id, trk_ids } => Self::Track { file_id: file_id.0, track_ids: trk_ids.iter().map(|id| id.0).collect(), @@ -187,6 +195,10 @@ impl From for engine::Selection { Selection::File { file_ids } => S::File { file_ids: file_ids.into_iter().map(FileId).collect(), }, + Selection::Route { file_id, route_ids } => S::Route { + file_id: FileId(file_id), + rte_ids: route_ids.into_iter().map(engine::RouteId).collect(), + }, Selection::Track { file_id, track_ids } => S::Track { file_id: FileId(file_id), trk_ids: track_ids.into_iter().map(engine::TrackId).collect(), @@ -229,6 +241,10 @@ pub enum MoveTarget { Files { index: f64, }, + Routes { + file_id: Uuid, + index: f64, + }, Tracks { file_id: Uuid, index: f64, @@ -250,6 +266,10 @@ impl From for engine::MoveTarget { let index = |index: f64| index.max(0.0) as usize; match target { MoveTarget::Files { index: i } => T::Files { index: index(i) }, + MoveTarget::Routes { file_id, index: i } => T::Routes { + file_id: FileId(file_id), + index: index(i), + }, MoveTarget::Tracks { file_id, index: i } => T::Tracks { file_id: FileId(file_id), index: index(i), @@ -286,6 +306,9 @@ impl Clipboard { engine::ClipboardIds::Files(ids) => { ("files", strings(ids.into_iter().map(|id| id.0).collect())) } + engine::ClipboardIds::Routes(ids) => { + ("routes", strings(ids.into_iter().map(|id| id.0).collect())) + } engine::ClipboardIds::Tracks(ids) => { ("tracks", strings(ids.into_iter().map(|id| id.0).collect())) } @@ -315,14 +338,15 @@ pub struct FileStructure { name: String, #[serde(skip_serializing_if = "Option::is_none")] desc: Option, + routes: Vec, tracks: Vec, waypoints: Vec, waypoints_rev: String, } +/// The name, description and style of a route or a track. #[derive(Serialize)] -struct TrackNode { - id: String, +struct PathInfo { #[serde(skip_serializing_if = "Option::is_none")] name: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -333,6 +357,33 @@ struct TrackNode { opacity: Option, #[serde(skip_serializing_if = "Option::is_none")] width: Option, +} + +impl From<&engine::PathInfo> for PathInfo { + fn from(info: &engine::PathInfo) -> Self { + Self { + name: info.name.clone(), + desc: info.desc.clone(), + color: info.color.clone(), + opacity: info.opacity, + width: info.width, + } + } +} + +#[derive(Serialize)] +struct RouteNode { + id: String, + #[serde(flatten)] + info: PathInfo, + segment: SegmentNode, +} + +#[derive(Serialize)] +struct TrackNode { + id: String, + #[serde(flatten)] + info: PathInfo, segments: Vec, } @@ -343,6 +394,16 @@ struct SegmentNode { length: f64, } +impl From<&engine::SegmentNode> for SegmentNode { + fn from(seg: &engine::SegmentNode) -> Self { + Self { + id: seg.id.0.to_string(), + rev: seg.rev_id.0.to_string(), + length: seg.len as f64, + } + } +} + #[derive(Serialize)] struct WaypointNode { id: String, @@ -358,25 +419,22 @@ impl FileStructure { id: file.id.0.to_string(), name: file.name.clone(), desc: file.desc.clone(), + routes: file + .routes + .iter() + .map(|rte| RouteNode { + id: rte.id.0.to_string(), + info: (&rte.info).into(), + segment: (&rte.segment).into(), + }) + .collect(), tracks: file .tracks .iter() .map(|trk| TrackNode { id: trk.id.0.to_string(), - name: trk.name.clone(), - desc: trk.desc.clone(), - color: trk.color.clone(), - opacity: trk.opacity, - width: trk.width, - segments: trk - .segments - .iter() - .map(|seg| SegmentNode { - id: seg.id.0.to_string(), - rev: seg.rev_id.0.to_string(), - length: seg.len as f64, - }) - .collect(), + info: (&trk.info).into(), + segments: trk.segments.iter().map(Into::into).collect(), }) .collect(), waypoints: file diff --git a/gpx-rs/src/api/queries.rs b/gpx-rs/src/api/queries.rs index f23ac4ace..5556e2296 100644 --- a/gpx-rs/src/api/queries.rs +++ b/gpx-rs/src/api/queries.rs @@ -76,9 +76,7 @@ pub fn trackpoint( } /// A file as GPX (UTF-8 bytes), `None` if it does not exist. The options say which data of the -/// trackpoints to keep, and with `as_route` that the segments are written as routes (`rte`) -/// instead of tracks: the anchors are the route points, and the trackpoints between them the -/// points of their path. +/// trackpoints to keep. pub fn export_file( engine: &Engine, file_id: Uuid, diff --git a/gpx-rs/src/core/algorithm/reverse.rs b/gpx-rs/src/core/algorithm/reverse.rs index a0dad8d8f..cfe702fc8 100644 --- a/gpx-rs/src/core/algorithm/reverse.rs +++ b/gpx-rs/src/core/algorithm/reverse.rs @@ -6,7 +6,7 @@ //! between them are kept: every part comes after the previous one in the new order, as late as the //! original one came after the part that followed it. -use crate::{Track, TrackSegment, Trackpoint}; +use crate::{Route, Track, TrackSegment, Trackpoint}; /// The time of the first trackpoint of a segment. pub fn start_time(segment: &TrackSegment) -> Option { @@ -94,6 +94,19 @@ pub fn reverse_segments( } } +/// Reverses the routes of a file, and their trackpoints, like the segments of a track (see +/// [`reverse_segments`]). +pub fn reverse_routes(routes: &mut [Route], original_next: Option, new_previous: Option) { + routes.reverse(); + let (mut next, mut previous) = (original_next, new_previous); + for route in routes.iter_mut() { + let original_start = start_time(&route.seg); + reverse_segment(&mut route.seg, next, previous); + next = original_start; + previous = end_time(&route.seg); + } +} + /// Reverses the tracks of a file, and their segments and trackpoints. pub fn reverse_tracks(tracks: &mut [Track], original_next: Option, new_previous: Option) { tracks.reverse(); diff --git a/gpx-rs/src/core/gpx/file.rs b/gpx-rs/src/core/gpx/file.rs index fe458238c..02472acee 100644 --- a/gpx-rs/src/core/gpx/file.rs +++ b/gpx-rs/src/core/gpx/file.rs @@ -1,4 +1,4 @@ -use crate::{Link, Track, Waypoints}; +use crate::{Link, Route, Track, TrackSegment, Waypoints}; use super::common::uuid_id; @@ -8,6 +8,9 @@ uuid_id!(FileId); pub struct File { pub id: FileId, pub info: FileInfo, + /// The routes come before the tracks, as in GPX: everything that goes through the segments + /// of a file (statistics, the elevation profile...) takes the ones of the routes first. + pub rte: Vec, pub trk: Vec, pub wpt: Waypoints, } @@ -29,3 +32,50 @@ pub struct Author { /// The author has a single link, unlike the other elements. pub link: Option, } + +/// Where a segment is in its file: the one of a route, or one of a track. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SegmentIndex { + Route(usize), + Track { trk: usize, seg: usize }, +} + +impl File { + /// The segments of the routes, then the ones of the tracks. + pub fn segments(&self) -> impl Iterator { + self.rte + .iter() + .map(|rte| &rte.seg) + .chain(self.trk.iter().flat_map(|trk| &trk.trkseg)) + } + + /// Where the segments are, in the order of [`File::segments`]. + pub fn segment_indices(&self) -> impl Iterator + '_ { + (0..self.rte.len()) + .map(SegmentIndex::Route) + .chain(self.trk.iter().enumerate().flat_map(|(trk, track)| { + (0..track.trkseg.len()).map(move |seg| SegmentIndex::Track { trk, seg }) + })) + } + + /// The segment at `index`. Panics if there is none. + pub fn segment(&self, index: SegmentIndex) -> &TrackSegment { + match index { + SegmentIndex::Route(rte) => &self.rte[rte].seg, + SegmentIndex::Track { trk, seg } => &self.trk[trk].trkseg[seg], + } + } + + /// The segment at `index`. Panics if there is none. + pub fn segment_mut(&mut self, index: SegmentIndex) -> &mut TrackSegment { + match index { + SegmentIndex::Route(rte) => &mut self.rte[rte].seg, + SegmentIndex::Track { trk, seg } => &mut self.trk[trk].trkseg[seg], + } + } + + /// Whether the file has no route nor track. + pub fn has_no_path(&self) -> bool { + self.rte.is_empty() && self.trk.is_empty() + } +} diff --git a/gpx-rs/src/core/gpx/mod.rs b/gpx-rs/src/core/gpx/mod.rs index 3ba1c5cc5..7aadecbab 100644 --- a/gpx-rs/src/core/gpx/mod.rs +++ b/gpx-rs/src/core/gpx/mod.rs @@ -3,6 +3,7 @@ mod chunk; mod chunked; mod common; mod file; +mod route; mod segment; mod track; mod trackpoint; @@ -14,6 +15,7 @@ pub use chunk::*; pub use chunked::*; pub use common::*; pub use file::*; +pub use route::*; pub use segment::*; pub use track::*; pub use trackpoint::*; diff --git a/gpx-rs/src/core/gpx/route.rs b/gpx-rs/src/core/gpx/route.rs new file mode 100644 index 000000000..45643a14c --- /dev/null +++ b/gpx-rs/src/core/gpx/route.rs @@ -0,0 +1,15 @@ +use crate::{TrackInfo, TrackSegment}; + +use super::common::uuid_id; + +uuid_id!(RouteId); + +/// A route of the file (`rte`): like a track, but with a single segment, its points. +/// +/// It has the information of a track: GPX gives the same to both. +#[derive(Debug, Default, Clone)] +pub struct Route { + pub id: RouteId, + pub info: TrackInfo, + pub seg: TrackSegment, +} diff --git a/gpx-rs/src/core/io/parse.rs b/gpx-rs/src/core/io/parse.rs index b8f8370b6..7cb7351ed 100644 --- a/gpx-rs/src/core/io/parse.rs +++ b/gpx-rs/src/core/io/parse.rs @@ -1,6 +1,6 @@ use crate::{ - Author, Chunk, File, Link, LngLat, Track, TrackSegment, Trackpoint, TrackpointCategories, - TrackpointChunk, Waypoint, WaypointChunk, distance, + Author, Chunk, File, Link, LngLat, Route, Track, TrackSegment, Trackpoint, + TrackpointCategories, TrackpointChunk, Waypoint, WaypointChunk, distance, }; use chrono::DateTime; use quick_xml::Error; @@ -18,6 +18,7 @@ enum GPXElement { Author(Author), Link(Link), Text, + /// A track, or a route, whose information is read the same way. Track(Track), Segment(TrackSegment), Trackpoint(Trackpoint), @@ -396,7 +397,7 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result = vec![]; let mut trkpt_chunk = TrackpointChunk::default(); let mut wpt_chunk = WaypointChunk::default(); - // Routes are read as tracks of a single segment, see `RouteReader`. The points of the detailed + // The segment of a route is read by `RouteReader`. The points of the detailed // path that a route point comes with (`gpxx:rpt`) are collected until the route point is read. let mut route = RouteReader::default(); let mut rpt_points: Vec = vec![]; @@ -530,12 +531,12 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result { - if let Some(GPXElement::Track(mut trk)) = stack.pop() { - let trkseg = std::mem::take(&mut route).finish(); - if !trkseg.is_empty() { - trk.trkseg.push(trkseg); - } - gpx.trk.push(trk); + if let Some(GPXElement::Track(trk)) = stack.pop() { + gpx.rte.push(Route { + info: trk.info, + seg: std::mem::take(&mut route).finish(), + ..Default::default() + }); } } "rtept" => { @@ -740,38 +741,37 @@ mod tests { } #[test] - fn test_parse_routes_as_tracks() { + fn test_parse_routes() { let gpx = parse_data("with_routes"); - assert_eq!(gpx.trk.len(), 2); - let trk = &gpx.trk[0]; - assert_eq!(trk.info.name.as_deref(), Some("route 1")); - assert_eq!(trk.info.type_.as_deref(), Some("Cycling")); - assert_eq!(trk.trkseg.len(), 1); - let trkseg = &trk.trkseg[0]; - assert_eq!(trkseg.len(), 49); - let trkpt = &trkseg[0]; + assert!(gpx.trk.is_empty()); + assert_eq!(gpx.rte.len(), 2); + let rte = &gpx.rte[0]; + assert_eq!(rte.info.name.as_deref(), Some("route 1")); + assert_eq!(rte.info.type_.as_deref(), Some("Cycling")); + assert_eq!(rte.seg.len(), 49); + let trkpt = &rte.seg[0]; assert_eq!(trkpt.coordinates.lat, 50.790867); assert_eq!(trkpt.coordinates.lng, 4.404968); assert_eq!(trkpt.ele, 109.0); - let trk = &gpx.trk[1]; - assert_eq!(trk.info.name.as_deref(), Some("route 2")); - assert_eq!(trk.trkseg[0].len(), 28); + let rte = &gpx.rte[1]; + assert_eq!(rte.info.name.as_deref(), Some("route 2")); + assert_eq!(rte.seg.len(), 28); + assert_ne!(gpx.rte[0].id, rte.id); } #[test] fn test_parse_route_point_extensions() { let gpx = parse_data("with_route_extensions"); - // a route without points has no segment - assert_eq!(gpx.trk.len(), 2); - assert!(gpx.trk[1].trkseg.is_empty()); + // a route without points has an empty segment + assert_eq!(gpx.rte.len(), 2); + assert!(gpx.rte[1].seg.is_empty()); - let trk = &gpx.trk[0]; - assert_eq!(trk.info.name.as_deref(), Some("with extensions")); - assert_eq!(trk.trkseg.len(), 1); - let points: Vec<_> = trk.trkseg[0].iter().collect(); + let rte = &gpx.rte[0]; + assert_eq!(rte.info.name.as_deref(), Some("with extensions")); + let points: Vec<_> = rte.seg.iter().collect(); // the route points are kept, the points of the detailed path follow the one they come with let coordinates: Vec<_> = points .iter() @@ -788,14 +788,13 @@ mod tests { #[test] fn test_route_points_are_the_anchors_and_nothing_else_is() { let gpx = parse_data("with_route_extensions"); - let anchors: Vec<_> = gpx.trk[0].trkseg[0].iter().map(|p| p.anchor).collect(); + let anchors: Vec<_> = gpx.rte[0].seg.iter().map(|p| p.anchor).collect(); assert_eq!(anchors, [Some(0), None, None, Some(0), Some(0)]); // no anchor is computed for the points of a route that has none in between let gpx = parse_data("with_routes"); - for trk in &gpx.trk { - let segment = &trk.trkseg[0]; - assert!(segment.iter().all(|p| p.anchor == Some(0))); + for rte in &gpx.rte { + assert!(rte.seg.iter().all(|p| p.anchor == Some(0))); } // while a track gets the anchors that its shape needs let track = parse_data("with_tracks"); @@ -807,14 +806,14 @@ mod tests { #[test] fn test_parse_the_points_of_the_detailed_path_of_a_route() { let gpx = parse_data("with_route_points"); - assert_eq!(gpx.trk.len(), 1); - let trk = &gpx.trk[0]; - assert_eq!(trk.info.name.as_deref(), Some("Lausanne")); - assert_eq!(trk.info.cmt.as_deref(), Some("comment")); - assert_eq!(trk.info.desc.as_deref(), Some("description")); - assert_eq!(trk.info.type_.as_deref(), Some("Cycling")); + assert_eq!(gpx.rte.len(), 1); + let rte = &gpx.rte[0]; + assert_eq!(rte.info.name.as_deref(), Some("Lausanne")); + assert_eq!(rte.info.cmt.as_deref(), Some("comment")); + assert_eq!(rte.info.desc.as_deref(), Some("description")); + assert_eq!(rte.info.type_.as_deref(), Some("Cycling")); - let points: Vec<_> = trk.trkseg[0].iter().cloned().collect(); + let points: Vec<_> = rte.seg.iter().cloned().collect(); // 3 route points and 4 points between them, the repeated one being kept assert_eq!(points.len(), 7); let kinds: Vec<_> = points.iter().map(|p| p.anchor.is_some()).collect(); @@ -846,7 +845,7 @@ mod tests { "#; let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap(); - let points: Vec<_> = gpx.trk[0].trkseg[0].iter().cloned().collect(); + let points: Vec<_> = gpx.rte[0].seg.iter().cloned().collect(); let coordinates: Vec<_> = points.iter().map(|p| p.coordinates.lat).collect(); assert_eq!(coordinates, [1.0, 2.0, 2.1, 2.2]); // the route point is an anchor, and so is the end of the path @@ -861,7 +860,7 @@ mod tests { "#; let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap(); - let anchors: Vec<_> = gpx.trk[0].trkseg[0].iter().map(|p| p.anchor).collect(); + let anchors: Vec<_> = gpx.rte[0].seg.iter().map(|p| p.anchor).collect(); assert_eq!(anchors, [Some(0), Some(0)]); } @@ -930,14 +929,14 @@ mod tests { assert_eq!(waypoints[0].links[2].text.as_deref(), Some("three")); assert!(waypoints[1].links.is_empty()); - // tracks, and routes which are read as tracks - assert_eq!(gpx.trk.len(), 2); + // tracks, and routes + assert_eq!((gpx.trk.len(), gpx.rte.len()), (1, 1)); assert_eq!( hrefs(&gpx.trk[0].info.links), ["https://example.com/trk-1", "https://example.com/trk-2"] ); assert_eq!( - hrefs(&gpx.trk[1].info.links), + hrefs(&gpx.rte[0].info.links), ["https://example.com/rte-1", "https://example.com/rte-2"] ); } diff --git a/gpx-rs/src/core/io/write.rs b/gpx-rs/src/core/io/write.rs index a9a967aed..7268f028e 100644 --- a/gpx-rs/src/core/io/write.rs +++ b/gpx-rs/src/core/io/write.rs @@ -8,9 +8,6 @@ use crate::{File, Link, Track, Trackpoint, TrackpointCategories, Waypoint}; /// What the written file contains: the data of the trackpoints that are `false` are left out. /// The same type tells which data a file has, see [`File::exportable_data`]. -/// -/// With `as_route`, the file is written with routes instead of tracks, see [`write`]. It is not -/// data: it is off in `ALL` and `NONE`, and in what [`File::exportable_data`] gives. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ExportOptions { pub time: bool, @@ -20,8 +17,6 @@ pub struct ExportOptions { pub power: bool, /// The OpenStreetMap data: surface, highway, SAC scale and MTB scale. pub osm: bool, - /// Writes every segment as a route (`rte`) instead of a track. - pub as_route: bool, } impl ExportOptions { @@ -36,7 +31,6 @@ impl ExportOptions { atemp: value, power: value, osm: value, - as_route: false, } } @@ -49,7 +43,6 @@ impl ExportOptions { atemp: self.atemp || other.atemp, power: self.power || other.power, osm: self.osm || other.osm, - as_route: self.as_route || other.as_route, } } } @@ -59,12 +52,7 @@ impl File { /// options of [`write`] can leave out. pub fn exportable_data(&self) -> ExportOptions { let mut data = ExportOptions::NONE; - for point in self - .trk - .iter() - .flat_map(|trk| &trk.trkseg) - .flat_map(|seg| seg.iter()) - { + for point in self.segments().flat_map(|seg| seg.iter()) { data.time |= point.time.is_some(); data.hr |= point.hr.is_some(); data.cad |= point.cad.is_some(); @@ -89,10 +77,9 @@ type XmlWriter = Writer>; /// Writes a file as GPX 1.1, in UTF-8. The surface, highway, SAC scale and MTB scale of the trackpoints are /// the names of their codes in `categories`. /// -/// The segments are written as tracks, or with `options.as_route` as routes: a segment becomes a -/// `rte` that has the information of its track, its anchors are the `rtept` and the trackpoints -/// between two anchors are the `gpxx:rpt` of the `rtept` before them (which only have a -/// position, unlike the `rtept`). The first and last trackpoints always are `rtept`. +/// The routes are written as routes (`rte`): the anchors of their segment are the `rtept` and the +/// trackpoints between two anchors are the `gpxx:rpt` of the `rtept` before them (which only have +/// a position, unlike the `rtept`). The first and last trackpoints always are `rtept`. pub fn write(file: &File, categories: &TrackpointCategories, options: ExportOptions) -> Vec { let mut writer = Writer::new_with_indent(Vec::new(), b' ', 4); // writing to memory does not fail @@ -100,9 +87,9 @@ pub fn write(file: &File, categories: &TrackpointCategories, options: ExportOpti writer.into_inner() } -fn write_file( +fn write_file<'a>( w: &mut XmlWriter, - file: &File, + file: &'a File, categories: &TrackpointCategories, options: ExportOptions, ) -> io::Result<()> { @@ -136,20 +123,18 @@ fn write_file( for wpt in file.wpt.iter() { write_waypoint(w, wpt, options)?; } + // a single unnamed route or track is named like the file + let alone = file.rte.len() + file.trk.len() == 1; + let name = |info: &'a crate::TrackInfo| match &info.name { + Some(name) if !name.is_empty() => Some(name.as_str()), + _ if alone => Some(file.info.name.as_str()), + _ => None, + }; + for rte in &file.rte { + write_route(w, &rte.info, &rte.seg, name(&rte.info), categories, options)?; + } for trk in &file.trk { - // a single unnamed track is named like the file - let name = match (&trk.info.name, file.trk.len()) { - (Some(name), _) if !name.is_empty() => Some(name.as_str()), - (_, 1) => Some(file.info.name.as_str()), - _ => None, - }; - if options.as_route { - for segment in trk.trkseg.iter().filter(|segment| !segment.is_empty()) { - write_route(w, trk, segment, name, categories, options)?; - } - } else { - write_track(w, trk, name, categories, options)?; - } + write_track(w, trk, name(&trk.info), categories, options)?; } Ok(()) })?; @@ -311,16 +296,16 @@ fn write_track( fn write_route( w: &mut XmlWriter, - trk: &Track, + info: &crate::TrackInfo, segment: &crate::TrackSegment, name: Option<&str>, categories: &TrackpointCategories, options: ExportOptions, ) -> io::Result<()> { w.create_element("rte").write_inner_content(|w| { - write_track_info(w, name, &trk.info)?; + write_track_info(w, name, info)?; let points: Vec<&Trackpoint> = segment.iter().collect(); - let last = points.len() - 1; + let last = points.len().saturating_sub(1); let is_anchor = |i: usize| i == 0 || i == last || points[i].anchor.is_some(); let mut i = 0; while i < points.len() { @@ -455,9 +440,7 @@ mod tests { } fn points(file: &File) -> Vec { - file.trk - .iter() - .flat_map(|trk| &trk.trkseg) + file.segments() .flat_map(|seg| seg.iter().cloned()) .collect() } @@ -595,11 +578,20 @@ mod tests { assert!(file.exportable_data().power); } - fn as_route() -> ExportOptions { - ExportOptions { - as_route: true, - ..ExportOptions::ALL - } + /// The file with each segment of its tracks as a route, which has the information of its + /// track. + fn as_routes(mut file: File) -> File { + file.rte = std::mem::take(&mut file.trk) + .into_iter() + .flat_map(|trk| { + trk.trkseg.into_iter().map(move |seg| crate::Route { + info: trk.info.clone(), + seg, + ..Default::default() + }) + }) + .collect(); + file } fn count(text: &str, pattern: &str) -> usize { @@ -607,19 +599,17 @@ mod tests { } #[test] - fn test_export_as_route_writes_routes_instead_of_tracks() { + fn test_routes_are_written_with_their_route_points_and_their_path() { let (file, categories) = read("with_route_points"); - let text = written_text(&file, &categories, as_route()); + let text = written_text(&file, &categories, ExportOptions::ALL); assert_eq!(count(&text, ""), 1); assert_eq!(count(&text, ""), 0); - assert_eq!(count(&text, ""), 0); assert_eq!(count(&text, ""), 2); - // a route has the information of its track assert!(text.contains("Lausanne")); assert!(text.contains("comment")); assert!(text.contains("description")); @@ -634,14 +624,14 @@ mod tests { } #[test] - fn test_a_route_survives_being_exported_as_a_route() { + fn test_a_route_survives_being_written() { let (file, mut categories) = read("with_route_points"); - let written = write(&file, &categories, as_route()); + let written = write(&file, &categories, ExportOptions::ALL); let again = parse(&written, &mut categories).unwrap(); - assert_eq!(again.trk.len(), 1); - assert_eq!(again.trk[0].info, file.trk[0].info); - let (a, b) = (&file.trk[0].trkseg[0], &again.trk[0].trkseg[0]); + assert_eq!(again.rte.len(), 1); + assert_eq!(again.rte[0].info, file.rte[0].info); + let (a, b) = (&file.rte[0].seg, &again.rte[0].seg); assert_eq!(a.len(), b.len()); for (a, b) in a.iter().zip(b.iter()) { assert_eq!(a.coordinates.lat, b.coordinates.lat); @@ -659,85 +649,102 @@ mod tests { } #[test] - fn test_tracks_become_one_route_per_segment_with_the_information_of_the_track() { + fn test_the_anchors_of_a_route_are_its_route_points() { let (file, mut categories) = read("with_tracks_and_segments"); - let segments: usize = file.trk.iter().map(|t| t.trkseg.len()).sum(); - assert!(segments > 2); - let written = write(&file, &categories, as_route()); - let text = String::from_utf8(written.clone()).unwrap(); - assert_eq!(count(&text, ""), segments); - assert_eq!(count(&text, ""), 0); - // every route starts and ends with a route point - assert_eq!(count(&text, ""), segments); - + let file = as_routes(file); + assert!(file.rte.len() > 2); + let written = write(&file, &categories, ExportOptions::ALL); let again = parse(&written, &mut categories).unwrap(); - assert_eq!(again.trk.len(), segments); - let mut routes = again.trk.iter(); - for track in &file.trk { - for segment in &track.trkseg { - let route = routes.next().unwrap(); - // the information of the track, repeated for each of its segments - assert_eq!(route.info.name, track.info.name); - assert_eq!(route.info.type_, track.info.type_); - let (a, b) = (segment, &route.trkseg[0]); - assert_eq!(a.len(), b.len()); - for (a, b) in a.iter().zip(b.iter()) { - assert_eq!( - (a.coordinates.lat, a.coordinates.lng), - (b.coordinates.lat, b.coordinates.lng) - ); - } - // the anchors of the segment are the ones of its route - let anchors = |s: &crate::TrackSegment| { - s.iter() - .enumerate() - .filter(|(_, p)| p.anchor.is_some()) - .map(|(i, _)| i) - .collect::>() - }; - assert_eq!(anchors(a), anchors(b)); - } + assert_eq!(again.rte.len(), file.rte.len()); + let anchors = |s: &crate::TrackSegment| { + s.iter() + .enumerate() + .filter(|(_, p)| p.anchor.is_some()) + .map(|(i, _)| i) + .collect::>() + }; + for (a, b) in file.rte.iter().zip(&again.rte) { + assert_eq!(a.info.name, b.info.name); + assert_eq!(a.seg.len(), b.seg.len()); + assert_eq!(anchors(&a.seg), anchors(&b.seg)); } } #[test] fn test_the_ends_of_a_route_are_route_points_even_without_anchors() { - let (mut file, categories) = read("simple"); - file.trk[0].trkseg[0].update_all(|_, point| point.anchor = None); - let len = file.trk[0].trkseg[0].len(); - let text = written_text(&file, &categories, as_route()); + let (file, categories) = read("simple"); + let mut file = as_routes(file); + file.rte[0].seg.update_all(|_, point| point.anchor = None); + let len = file.rte[0].seg.len(); + let text = written_text(&file, &categories, ExportOptions::ALL); assert_eq!(count(&text, ""), 1); - } - - #[test] - fn test_routes_have_the_style_of_their_track() { + fn test_routes_keep_their_style() { let (file, categories) = read("with_style"); - let text = written_text(&file, &categories, as_route()); + let file = as_routes(file); + let text = written_text(&file, &categories, ExportOptions::ALL); assert!(count(&text, "") >= 1); assert_eq!(count(&text, ""), count(&text, "")); let again = parse(text.as_bytes(), &mut Default::default()).unwrap(); - assert_eq!(again.trk[0].info.color, file.trk[0].info.color); - assert_eq!(again.trk[0].info.width, file.trk[0].info.width); + assert_eq!(again.rte[0].info.color, file.rte[0].info.color); + assert_eq!(again.rte[0].info.width, file.rte[0].info.width); + } + + #[test] + fn test_routes_are_written_as_routes_before_the_tracks() { + let (mut file, categories) = read("with_tracks"); + let (routes, _) = read("with_routes"); + file.rte = routes.rte.clone(); + let text = written_text(&file, &categories, ExportOptions::ALL); + assert_eq!(count(&text, ""), routes.rte.len()); + assert_eq!(count(&text, ""), file.trk.len()); + assert!(text.rfind("").unwrap() < text.find("").unwrap()); + + let again = parse(text.as_bytes(), &mut categories.clone()).unwrap(); + assert_eq!(again.rte.len(), file.rte.len()); + assert_eq!(again.trk.len(), file.trk.len()); + for (a, b) in file.rte.iter().zip(&again.rte) { + assert_eq!(a.info, b.info); + same_points( + &a.seg.iter().cloned().collect::>(), + &b.seg.iter().cloned().collect::>(), + ); + } + same_points(&points(&file), &points(&again)); + } + + #[test] + fn test_an_empty_route_is_written_and_a_single_route_is_named_like_the_file() { + let mut file = File::default(); + file.info.name = "file".into(); + file.rte.push(crate::Route::default()); + let text = written_text(&file, &Default::default(), ExportOptions::ALL); + assert_eq!(count(&text, ""), 1); + assert_eq!(count(&text, "file"), 1); } #[test] fn test_route_points_keep_their_data_and_the_options_apply_to_them() { let (file, categories) = read("with_hr"); - let with_all = written_text(&file, &categories, as_route()); + let file = as_routes(file); + let with_all = written_text(&file, &categories, ExportOptions::ALL); assert!(with_all.contains("")); let without = written_text( &file, @@ -745,22 +752,10 @@ mod tests { ExportOptions { hr: false, time: false, - as_route: true, ..ExportOptions::ALL }, ); assert!(!without.contains("")); assert!(!without.contains("