From dc36de2a482a9f565ebbf3c262f5e2a3f3d01fd8 Mon Sep 17 00:00:00 2001 From: vcoppe Date: Fri, 9 Oct 2026 09:26:38 +0200 Subject: [PATCH] import and export of routes, ref #192 --- gpx-rs/engine/data/with_route_points.gpx | 30 ++ gpx-rs/engine/src/core/io/parse.rs | 244 ++++++++++++-- gpx-rs/engine/src/core/io/write.rs | 301 ++++++++++++++++-- gpx-rs/wasm/src/queries/files.rs | 6 +- gpx-rs/wasm/src/tests.rs | 25 +- .../src/lib/components/export/Export.svelte | 12 + website/src/lib/engine/engine.test.ts | 52 ++- website/src/lib/engine/engine.ts | 5 +- website/src/lib/engine/types.ts | 6 + website/src/locales/en.json | 1 + 10 files changed, 620 insertions(+), 62 deletions(-) create mode 100644 gpx-rs/engine/data/with_route_points.gpx diff --git a/gpx-rs/engine/data/with_route_points.gpx b/gpx-rs/engine/data/with_route_points.gpx new file mode 100644 index 000000000..41be56470 --- /dev/null +++ b/gpx-rs/engine/data/with_route_points.gpx @@ -0,0 +1,30 @@ + + + + Lausanne + comment + description + Cycling + + 500 + + + + + + + + + + 510 + + + + + + + + 505 + + + diff --git a/gpx-rs/engine/src/core/io/parse.rs b/gpx-rs/engine/src/core/io/parse.rs index c5818056e..b8f8370b6 100644 --- a/gpx-rs/engine/src/core/io/parse.rs +++ b/gpx-rs/engine/src/core/io/parse.rs @@ -1,6 +1,6 @@ use crate::{ Author, Chunk, File, Link, LngLat, Track, TrackSegment, Trackpoint, TrackpointCategories, - TrackpointChunk, Waypoint, WaypointChunk, + TrackpointChunk, Waypoint, WaypointChunk, distance, }; use chrono::DateTime; use quick_xml::Error; @@ -300,6 +300,93 @@ fn attach_link(stack: &mut [GPXElement], gpx: &mut File, link: Link) { } } +/// Builds the segment of a route. +/// +/// The route points are the anchors of the segment, and are the only ones that the file gives +/// (no anchor is computed). The points of the detailed path between a route point and the next one +/// (`gpxx:rpt`, which only have a position) are regular trackpoints, with the elevation worked out +/// from the one of the route points around them. +#[derive(Default)] +struct RouteReader { + segment: TrackSegment, + chunk: TrackpointChunk, + /// The intermediate points that follow the last route point, up to the next one. + pending: Vec, + /// Position and elevation of the last route point. + previous: Option<(LngLat, f64)>, +} + +impl RouteReader { + fn push(&mut self, point: Trackpoint) { + self.chunk.trkpt.push(point); + if self.chunk.is_full() { + self.segment.push(std::mem::take(&mut self.chunk)); + } + } + + /// Adds a route point, after the intermediate points of the previous one. `following` are the + /// intermediate points that lead from it to the next one. + fn push_route_point(&mut self, mut rtept: Trackpoint, following: Vec) { + rtept.anchor = Some(0); + let here = (rtept.coordinates, rtept.ele); + self.flush(Some(here)); + self.previous = Some(here); + self.push(rtept); + self.pending = following; + } + + /// Adds the pending intermediate points, which lead to the route point `next` if there is one. + fn flush(&mut self, next: Option<(LngLat, f64)>) { + let mut pending = std::mem::take(&mut self.pending); + if let Some(previous) = self.previous { + interpolate_elevations(&mut pending, previous, next); + } + for point in pending { + self.push(point); + } + } + + fn finish(mut self) -> TrackSegment { + self.flush(None); + self.segment.push(std::mem::take(&mut self.chunk)); + self.segment.ensure_end_anchors(); + self.segment + } +} + +/// Gives the points of a path from `from` to `to` an elevation that goes from the one of the first +/// to the one of the second, in proportion to the distance along the path. Without `to`, they have +/// the elevation of `from`. +fn interpolate_elevations( + points: &mut [Trackpoint], + from: (LngLat, f64), + to: Option<(LngLat, f64)>, +) { + let Some((to_position, to_ele)) = to else { + points.iter_mut().for_each(|point| point.ele = from.1); + return; + }; + let mut distances = Vec::with_capacity(points.len() + 1); + let mut total = 0.0; + let mut last = from.0; + for position in points + .iter() + .map(|point| point.coordinates) + .chain([to_position]) + { + total += distance(last, position); + distances.push(total); + last = position; + } + for (point, along) in points.iter_mut().zip(distances) { + point.ele = if total > 0.0 { + from.1 + (to_ele - from.1) * along / total + } else { + from.1 + }; + } +} + /// Parses a GPX file. The surface, highway, SAC scale and MTB scale of the trackpoints are stored as codes of /// `categories`, which learns the values it does not know yet. pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result { @@ -309,9 +396,9 @@ 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, which is what `rte_segment` collects the points of. A route - // point that comes with the detailed path of the route to the next one (`gpxx:rpt`) is replaced by it. - let mut rte_segment = TrackSegment::default(); + // Routes are read as tracks of a single segment, see `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![]; // the text of the element at the top of the stack, up to now let mut text = String::new(); @@ -381,13 +468,13 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result { if let Some(GPXElement::Track(_)) = stack.last() { - trkpt_chunk.trkpt.push(Trackpoint { - coordinates: parse_coordinates(e.attributes()), - ..Default::default() - }); - if trkpt_chunk.is_full() { - rte_segment.push(std::mem::take(&mut trkpt_chunk)); - } + route.push_route_point( + Trackpoint { + coordinates: parse_coordinates(e.attributes()), + ..Default::default() + }, + vec![], + ); } } name if is_route_point_extension(name) => { @@ -444,10 +531,8 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result { if let Some(GPXElement::Track(mut trk)) = stack.pop() { - rte_segment.push(std::mem::take(&mut trkpt_chunk)); - if !rte_segment.is_empty() { - let mut trkseg = std::mem::take(&mut rte_segment); - trkseg.compute_anchors(); + let trkseg = std::mem::take(&mut route).finish(); + if !trkseg.is_empty() { trk.trkseg.push(trkseg); } gpx.trk.push(trk); @@ -457,17 +542,7 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result { @@ -697,17 +772,128 @@ mod tests { assert_eq!(trk.info.name.as_deref(), Some("with extensions")); assert_eq!(trk.trkseg.len(), 1); let points: Vec<_> = trk.trkseg[0].iter().collect(); - // the detailed path replaces the route point it comes with + // the route points are kept, the points of the detailed path follow the one they come with let coordinates: Vec<_> = points .iter() .map(|p| (p.coordinates.lat, p.coordinates.lng)) .collect(); assert_eq!( coordinates, - [(1.1, 1.1), (1.2, 1.2), (2.0, 2.0), (3.0, 3.0)] + [(1.0, 1.0), (1.1, 1.1), (1.2, 1.2), (2.0, 2.0), (3.0, 3.0)] ); + assert_eq!(points[3].ele, 20.0); + assert!(points[3].time.is_some()); + } + + #[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(); + 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))); + } + // while a track gets the anchors that its shape needs + let track = parse_data("with_tracks"); + let segment = &track.trk[0].trkseg[0]; + let anchors = segment.iter().filter(|p| p.anchor.is_some()).count(); + assert!(anchors >= 2 && anchors < segment.len()); + } + + #[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")); + + let points: Vec<_> = trk.trkseg[0].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(); + assert_eq!(kinds, [true, false, false, false, true, false, true]); + assert_eq!(points[0].coordinates.lat, 46.54462809674442); + assert_eq!(points[0].coordinates.lng, 6.658079791814089); + assert_eq!(points[1].coordinates.lat, 46.54448392800987); + assert_eq!(points[3].coordinates.lng, points[2].coordinates.lng); + assert_eq!(points[4].coordinates.lat, 46.5441); + assert_eq!(points[5].coordinates.lat, 46.5439); + + // the points in between have no elevation of their own: it goes from one route point to + // the next + let ele: Vec<_> = points.iter().map(|p| p.ele).collect(); + assert_eq!((ele[0], ele[4], ele[6]), (500.0, 510.0, 505.0)); + assert!(ele[0] < ele[1] && ele[1] < ele[2] && ele[2] <= ele[3] && ele[3] < ele[4]); + assert!(ele[5] < 510.0 && ele[5] > 505.0); + } + + #[test] + fn test_the_last_point_of_a_route_is_an_anchor_even_after_a_path() { + // the last route point comes with a path that leads nowhere + let data = r#" + 10 + 20 + + + + + "#; + let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap(); + let points: Vec<_> = gpx.trk[0].trkseg[0].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 + let anchors: Vec<_> = points.iter().map(|p| p.anchor).collect(); + assert_eq!(anchors, [Some(0), Some(0), None, Some(0)]); + // with the elevation of the last route point, as there is no next one assert_eq!(points[2].ele, 20.0); - assert!(points[2].time.is_some()); + assert_eq!(points[3].ele, 20.0); + + // a path after a single route point + let data = r#" + + "#; + let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap(); + let anchors: Vec<_> = gpx.trk[0].trkseg[0].iter().map(|p| p.anchor).collect(); + assert_eq!(anchors, [Some(0), Some(0)]); + } + + #[test] + fn test_elevations_between_route_points() { + let here = |lng: f64| LngLat { lng, lat: 0.0 }; + let point = |lng: f64| Trackpoint { + coordinates: here(lng), + ..Default::default() + }; + // evenly spaced points go up evenly + let mut points = vec![point(1.0), point(2.0), point(3.0)]; + interpolate_elevations(&mut points, (here(0.0), 100.0), Some((here(4.0), 200.0))); + let ele: Vec<_> = points.iter().map(|p| p.ele.round()).collect(); + assert_eq!(ele, [125.0, 150.0, 175.0]); + + // the distance counts, not the number of points + let mut points = vec![point(1.0)]; + interpolate_elevations(&mut points, (here(0.0), 0.0), Some((here(4.0), 400.0))); + assert!((points[0].ele - 100.0).abs() < 1e-6); + + // after the last route point, the elevation does not change + let mut points = vec![point(1.0), point(2.0)]; + interpolate_elevations(&mut points, (here(0.0), 321.0), None); + assert!(points.iter().all(|p| p.ele == 321.0)); + + // route points at the same place + let mut points = vec![point(0.0)]; + interpolate_elevations(&mut points, (here(0.0), 10.0), Some((here(0.0), 20.0))); + assert_eq!(points[0].ele, 10.0); + // nothing between them is fine + interpolate_elevations(&mut [], (here(0.0), 1.0), Some((here(1.0), 2.0))); } #[test] diff --git a/gpx-rs/engine/src/core/io/write.rs b/gpx-rs/engine/src/core/io/write.rs index 7e21a5d86..a9a967aed 100644 --- a/gpx-rs/engine/src/core/io/write.rs +++ b/gpx-rs/engine/src/core/io/write.rs @@ -8,6 +8,9 @@ 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, @@ -17,6 +20,8 @@ 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 { @@ -31,6 +36,7 @@ impl ExportOptions { atemp: value, power: value, osm: value, + as_route: false, } } @@ -43,6 +49,7 @@ 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, } } } @@ -81,6 +88,11 @@ 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`. 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 @@ -131,7 +143,13 @@ fn write_file( (_, 1) => Some(file.info.name.as_str()), _ => None, }; - write_track(w, trk, name, categories, options)?; + 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)?; + } } Ok(()) })?; @@ -237,6 +255,38 @@ fn write_waypoint(w: &mut XmlWriter, wpt: &Waypoint, options: ExportOptions) -> Ok(()) } +/// The information of a track that its routes have too: name, comment, description, source, +/// links, type and style. +fn write_track_info( + w: &mut XmlWriter, + name: Option<&str>, + info: &crate::TrackInfo, +) -> io::Result<()> { + text(w, "name", name.unwrap_or_default())?; + optional_text(w, "cmt", &info.cmt)?; + optional_text(w, "desc", &info.desc)?; + optional_text(w, "src", &info.src)?; + links(w, &info.links)?; + optional_text(w, "type", &info.type_)?; + if info.color.is_some() || info.opacity.is_some() || info.width.is_some() { + w.create_element("extensions").write_inner_content(|w| { + w.create_element("gpx_style:line") + .write_inner_content(|w| { + optional_text(w, "gpx_style:color", &info.color)?; + if let Some(opacity) = info.opacity { + number(w, "gpx_style:opacity", opacity)?; + } + if let Some(width) = info.width { + number(w, "gpx_style:width", width)?; + } + Ok(()) + })?; + Ok(()) + })?; + } + Ok(()) +} + fn write_track( w: &mut XmlWriter, trk: &Track, @@ -244,34 +294,12 @@ fn write_track( categories: &TrackpointCategories, options: ExportOptions, ) -> io::Result<()> { - let info = &trk.info; w.create_element("trk").write_inner_content(|w| { - text(w, "name", name.unwrap_or_default())?; - optional_text(w, "cmt", &info.cmt)?; - optional_text(w, "desc", &info.desc)?; - optional_text(w, "src", &info.src)?; - links(w, &info.links)?; - optional_text(w, "type", &info.type_)?; - if info.color.is_some() || info.opacity.is_some() || info.width.is_some() { - w.create_element("extensions").write_inner_content(|w| { - w.create_element("gpx_style:line") - .write_inner_content(|w| { - optional_text(w, "gpx_style:color", &info.color)?; - if let Some(opacity) = info.opacity { - number(w, "gpx_style:opacity", opacity)?; - } - if let Some(width) = info.width { - number(w, "gpx_style:width", width)?; - } - Ok(()) - })?; - Ok(()) - })?; - } + write_track_info(w, name, &trk.info)?; for segment in &trk.trkseg { w.create_element("trkseg").write_inner_content(|w| { for point in segment.iter() { - write_trackpoint(w, point, categories, options)?; + write_point(w, "trkpt", point, &[], categories, options)?; } Ok(()) })?; @@ -281,9 +309,48 @@ fn write_track( Ok(()) } -fn write_trackpoint( +fn write_route( w: &mut XmlWriter, + trk: &Track, + 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)?; + let points: Vec<&Trackpoint> = segment.iter().collect(); + let last = points.len() - 1; + let is_anchor = |i: usize| i == 0 || i == last || points[i].anchor.is_some(); + let mut i = 0; + while i < points.len() { + // the trackpoints up to the next anchor + let mut next = i + 1; + while next < points.len() && !is_anchor(next) { + next += 1; + } + write_point( + w, + "rtept", + points[i], + &points[i + 1..next], + categories, + options, + )?; + i = next; + } + Ok(()) + })?; + Ok(()) +} + +/// A trackpoint or a route point (`tag`). A route point has the `path` that leads to the next one, +/// of which only the positions are written. +fn write_point( + w: &mut XmlWriter, + tag: &str, point: &Trackpoint, + path: &[&Trackpoint], categories: &TrackpointCategories, options: ExportOptions, ) -> io::Result<()> { @@ -306,12 +373,12 @@ fn write_trackpoint( let has_osm = osm.iter().any(|(_, value)| value.is_some()); let has_track_point_extension = atemp.is_some() || hr.is_some() || cad.is_some() || has_osm; - write_coordinates(w, "trkpt", point.coordinates).write_inner_content(|w| { + write_coordinates(w, tag, point.coordinates).write_inner_content(|w| { number(w, "ele", point.ele)?; if options.time { time(w, point.time)?; } - if !has_track_point_extension && power.is_none() { + if !has_track_point_extension && power.is_none() && path.is_empty() { return Ok(()); } w.create_element("extensions").write_inner_content(|w| { @@ -343,6 +410,15 @@ fn write_trackpoint( w.create_element("gpxpx:PowerExtension") .write_inner_content(|w| number(w, "gpxpx:PowerInWatts", power))?; } + if !path.is_empty() { + w.create_element("gpxx:RoutePointExtension") + .write_inner_content(|w| { + for point in path { + write_coordinates(w, "gpxx:rpt", point.coordinates).write_empty()?; + } + Ok(()) + })?; + } Ok(()) })?; Ok(()) @@ -518,4 +594,173 @@ mod tests { let (file, _) = read("with_power_2"); assert!(file.exportable_data().power); } + + fn as_route() -> ExportOptions { + ExportOptions { + as_route: true, + ..ExportOptions::ALL + } + } + + fn count(text: &str, pattern: &str) -> usize { + text.matches(pattern).count() + } + + #[test] + fn test_export_as_route_writes_routes_instead_of_tracks() { + let (file, categories) = read("with_route_points"); + let text = written_text(&file, &categories, as_route()); + + 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")); + assert!(text.contains("Cycling")); + // and its points only have a position in the path, and an elevation as route points + let first_rtept = &text[text.find(""))); + assert!(first_rtept.contains("500")); + let (lat, lng) = (46.54441595077515_f64, 6.658358573913574_f64); + assert!(text.contains(&format!(""))); + } + + #[test] + fn test_a_route_survives_being_exported_as_a_route() { + let (file, mut categories) = read("with_route_points"); + let written = write(&file, &categories, as_route()); + 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!(a.len(), b.len()); + for (a, b) in a.iter().zip(b.iter()) { + assert_eq!(a.coordinates.lat, b.coordinates.lat); + assert_eq!(a.coordinates.lng, b.coordinates.lng); + assert_eq!(a.anchor, b.anchor); + } + // the elevation of the route points is kept, the other ones are worked out again + let anchors = |s: &crate::TrackSegment| { + s.iter() + .filter(|p| p.anchor.is_some()) + .map(|p| p.ele) + .collect::>() + }; + assert_eq!(anchors(a), anchors(b)); + } + + #[test] + fn test_tracks_become_one_route_per_segment_with_the_information_of_the_track() { + 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 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)); + } + } + } + + #[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()); + assert_eq!(count(&text, ""), 1); + } + + #[test] + fn test_routes_have_the_style_of_their_track() { + let (file, categories) = read("with_style"); + let text = written_text(&file, &categories, as_route()); + 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); + } + + #[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()); + assert!(with_all.contains("")); + let without = written_text( + &file, + &categories, + ExportOptions { + hr: false, + time: false, + as_route: true, + ..ExportOptions::ALL + }, + ); + assert!(!without.contains("")); + assert!(!without.contains("