From 764d0b233a51b438081e14986691788b60a283c3 Mon Sep 17 00:00:00 2001 From: vcoppe Date: Mon, 26 Oct 2026 19:06:19 +0100 Subject: [PATCH] route - track conversion --- gpx-rs/src/api/commands.rs | 5 + gpx-rs/src/engine/command/edit/convert.rs | 128 ++++++++++++++++++ gpx-rs/src/engine/command/edit/mod.rs | 2 + gpx-rs/src/engine/command/mod.rs | 2 + gpx-rs/src/engine/command/routes_tests.rs | 86 +++++++++++- tauri/src/commands/edit.rs | 5 + tauri/src/lib.rs | 1 + wasm/src/command/edit.rs | 6 + website/src/lib/components/Menu.svelte | 14 ++ .../file-list/FileListNodeLabel.svelte | 9 ++ website/src/lib/engine/backend/tauri.ts | 1 + website/src/lib/engine/backend/types.ts | 1 + website/src/lib/engine/backend/wasm.ts | 1 + website/src/lib/engine/engine.ts | 8 ++ website/src/locales/en.json | 2 + 15 files changed, 268 insertions(+), 3 deletions(-) create mode 100644 gpx-rs/src/engine/command/edit/convert.rs diff --git a/gpx-rs/src/api/commands.rs b/gpx-rs/src/api/commands.rs index c55081b4b..70e7481ed 100644 --- a/gpx-rs/src/api/commands.rs +++ b/gpx-rs/src/api/commands.rs @@ -317,6 +317,11 @@ pub fn style( ) } +/// Converts the selected routes to tracks, or the selected tracks or segments to routes. +pub fn convert(engine: &mut Engine) -> Outcome { + edit(engine, Command::Convert(engine::Convert)) +} + pub fn new_route(engine: &mut Engine) -> Outcome { edit(engine, Command::NewRoute(engine::NewRoute)) } diff --git a/gpx-rs/src/engine/command/edit/convert.rs b/gpx-rs/src/engine/command/edit/convert.rs new file mode 100644 index 000000000..ffedb75e2 --- /dev/null +++ b/gpx-rs/src/engine/command/edit/convert.rs @@ -0,0 +1,128 @@ +use std::{collections::HashSet, rc::Rc}; + +use crate::{Apply, CommandError, FileId, Route, Selection, State, Track}; + +/// Converts the selected routes to tracks, or the selected tracks or segments to routes, in their +/// file: +/// +/// - a route becomes a track with its segment. The new tracks go before the other tracks, right +/// after the routes; +/// - a track becomes one route per segment, as a route has a single segment, and goes away. A +/// selected segment becomes a route and leaves its track, which stays even if it has no segment +/// left. The routes have the information of their track, and go after the other routes. +/// +/// The segments keep their ids, the new routes and tracks are selected. There is nothing to do +/// for the other selections, or for tracks without segments. +#[derive(Debug)] +pub struct Convert; + +impl Apply for Convert { + fn apply(self, state: &mut State) -> Result<(), CommandError> { + let selection = state.selection.clone(); + let next = match &selection { + Selection::Route { file_id, rte_ids } => { + let file = Rc::make_mut( + state + .files + .get_mut(file_id) + .ok_or(CommandError::NothingToDo)?, + ); + let (converted, kept) = std::mem::take(&mut file.rte) + .into_iter() + .partition::, _>(|rte| rte_ids.contains(&rte.id)); + file.rte = kept; + let tracks: Vec = converted + .into_iter() + .map(|rte| Track { + id: Default::default(), + info: rte.info, + trkseg: vec![rte.seg], + }) + .collect(); + let trk_ids: HashSet<_> = tracks.iter().map(|trk| trk.id).collect(); + file.trk.splice(0..0, tracks); + Selection::Track { + file_id: *file_id, + trk_ids, + } + } + Selection::Track { file_id, trk_ids } => { + let file = Rc::make_mut( + state + .files + .get_mut(file_id) + .ok_or(CommandError::NothingToDo)?, + ); + let (converted, kept) = std::mem::take(&mut file.trk) + .into_iter() + .partition::, _>(|trk| trk_ids.contains(&trk.id)); + file.trk = kept; + let routes = converted.into_iter().flat_map(|trk| { + trk.trkseg.into_iter().map(move |seg| Route { + id: Default::default(), + info: trk.info.clone(), + seg, + }) + }); + add_routes(&mut file.rte, *file_id, routes) + } + Selection::TrackSegment { + file_id, + trk_id, + trkseg_ids, + } => { + let file = Rc::make_mut( + state + .files + .get_mut(file_id) + .ok_or(CommandError::NothingToDo)?, + ); + let trk = file + .trk + .iter_mut() + .find(|trk| trk.id == *trk_id) + .ok_or(CommandError::NothingToDo)?; + let (converted, kept) = std::mem::take(&mut trk.trkseg) + .into_iter() + .partition::, _>(|seg| trkseg_ids.contains(&seg.id)); + trk.trkseg = kept; + let info = trk.info.clone(); + let routes = converted.into_iter().map(|seg| Route { + id: Default::default(), + info: info.clone(), + seg, + }); + add_routes(&mut file.rte, *file_id, routes) + } + Selection::Empty + | Selection::File { .. } + | Selection::Waypoints { .. } + | Selection::Waypoint { .. } => return Err(CommandError::NothingToDo), + }; + let empty = match &next { + Selection::Route { rte_ids, .. } => rte_ids.is_empty(), + Selection::Track { trk_ids, .. } => trk_ids.is_empty(), + _ => true, + }; + if empty { + // nothing was converted (the changed copy of the file is dropped) + return Err(CommandError::NothingToDo); + } + *state.selection = next; + Ok(()) + } +} + +/// Adds the routes after the other routes, and gives the selection of them. +fn add_routes( + rte: &mut Vec, + file_id: FileId, + routes: impl Iterator, +) -> Selection { + let start = rte.len(); + rte.extend(routes); + Selection::Route { + file_id, + rte_ids: rte[start..].iter().map(|rte| rte.id).collect(), + } +} diff --git a/gpx-rs/src/engine/command/edit/mod.rs b/gpx-rs/src/engine/command/edit/mod.rs index 4e6c7c571..92c74dae6 100644 --- a/gpx-rs/src/engine/command/edit/mod.rs +++ b/gpx-rs/src/engine/command/edit/mod.rs @@ -1,9 +1,11 @@ +mod convert; mod metadata; mod new_route; mod new_track; mod new_track_segment; mod style; +pub use convert::*; pub use metadata::*; pub use new_route::*; pub use new_track::*; diff --git a/gpx-rs/src/engine/command/mod.rs b/gpx-rs/src/engine/command/mod.rs index 0ba204e9a..8043b5325 100644 --- a/gpx-rs/src/engine/command/mod.rs +++ b/gpx-rs/src/engine/command/mod.rs @@ -33,6 +33,7 @@ pub enum Command<'a> { Move(Move), DeleteAll(DeleteAll), DeleteWaypoint(DeleteWaypoint), + Convert(Convert), Metadata(Metadata<'a>), Style(Style<'a>), NewRoute(NewRoute), @@ -69,6 +70,7 @@ impl Apply for Command<'_> { Command::Move(c) => c.apply(state), Command::DeleteAll(c) => c.apply(state), Command::DeleteWaypoint(c) => c.apply(state), + Command::Convert(c) => c.apply(state), Command::Metadata(c) => c.apply(state), Command::Style(c) => c.apply(state), Command::NewRoute(c) => c.apply(state), diff --git a/gpx-rs/src/engine/command/routes_tests.rs b/gpx-rs/src/engine/command/routes_tests.rs index a4e0f41de..0d6bf5dd9 100644 --- a/gpx-rs/src/engine/command/routes_tests.rs +++ b/gpx-rs/src/engine/command/routes_tests.rs @@ -3,9 +3,9 @@ use std::rc::Rc; use crate::{ - Apply, Clean, CleanType, Clipboard, ClipboardContent, CommandError, Crop, Delete, Duplicate, - Extract, File, FileId, LngLat, LngLatBounds, Merge, MergeType, Metadata, Move, MoveTarget, - Paste, Reverse, RouteId, SegmentIndex, Selection, Split, SplitType, Style, + Apply, Clean, CleanType, Clipboard, ClipboardContent, CommandError, Convert, Crop, Delete, + Duplicate, Extract, File, FileId, LngLat, LngLatBounds, Merge, MergeType, Metadata, Move, + MoveTarget, Paste, Reverse, RouteId, SegmentIndex, Selection, Split, SplitType, Style, engine::command::fixture::Fixture, parse, }; @@ -602,3 +602,83 @@ fn test_reverse_a_route_and_the_routes_of_a_file() { assert_eq!(file.rte[0].id, before.rte[1].id); assert_eq!(file.trk[0].id, before.trk[1].id); } + +#[test] +fn test_convert_routes_to_tracks_and_back() { + let (mut fx, id) = fixture(); + let before = file(&fx, id).clone(); + + // a route becomes a track, first among the tracks, with its segment and its information + fx.selection = routes(&fx, id, &[1]); + Convert.apply(&mut fx.state()).unwrap(); + let converted = file(&fx, id); + assert_eq!((converted.rte.len(), converted.trk.len()), (1, 3)); + assert_eq!(converted.trk[0].info, before.rte[1].info); + assert_eq!(converted.trk[0].trkseg.len(), 1); + assert_eq!(converted.trk[0].trkseg[0].id, before.rte[1].seg.id); + assert_eq!( + fx.selection, + Selection::Track { + file_id: id, + trk_ids: [converted.trk[0].id].into() + } + ); + + // and back: a route again, after the other routes + Convert.apply(&mut fx.state()).unwrap(); + let back = file(&fx, id); + assert_eq!((back.rte.len(), back.trk.len()), (2, 2)); + assert_eq!(back.rte[1].seg.id, before.rte[1].seg.id); + assert_eq!(back.rte[1].info, before.rte[1].info); + assert!( + matches!(&fx.selection, Selection::Route { rte_ids, .. } if rte_ids.contains(&back.rte[1].id)) + ); +} + +#[test] +fn test_convert_a_track_to_one_route_per_segment() { + let (mut fx, id) = fixture(); + let track = file(&fx, id).trk[0].clone(); + assert_eq!(track.trkseg.len(), 2); + fx.selection = Selection::Track { + file_id: id, + trk_ids: [track.id].into(), + }; + Convert.apply(&mut fx.state()).unwrap(); + let converted = file(&fx, id); + assert_eq!((converted.rte.len(), converted.trk.len()), (4, 1)); + for (route, segment) in converted.rte[2..].iter().zip(&track.trkseg) { + assert_eq!(route.seg.id, segment.id); + assert_eq!(route.info, track.info); + } + assert!(matches!(&fx.selection, Selection::Route { rte_ids, .. } if rte_ids.len() == 2)); +} + +#[test] +fn test_convert_segments_to_routes_keeps_their_track() { + let (mut fx, id) = fixture(); + let track = file(&fx, id).trk[1].clone(); + fx.selection = Selection::TrackSegment { + file_id: id, + trk_id: track.id, + trkseg_ids: [track.trkseg[0].id, track.trkseg[1].id].into(), + }; + Convert.apply(&mut fx.state()).unwrap(); + let converted = file(&fx, id); + assert_eq!((converted.rte.len(), converted.trk.len()), (4, 2)); + assert!(converted.trk[1].trkseg.is_empty()); + assert_eq!(converted.rte[2].seg.id, track.trkseg[0].id); + + // nothing to convert otherwise + for selection in [ + whole(id), + Selection::Empty, + Selection::Waypoints { file_id: id }, + ] { + fx.selection = selection; + assert_eq!( + Convert.apply(&mut fx.state()), + Err(CommandError::NothingToDo) + ); + } +} diff --git a/tauri/src/commands/edit.rs b/tauri/src/commands/edit.rs index 9f5ec0cf1..b208bbd0a 100644 --- a/tauri/src/commands/edit.rs +++ b/tauri/src/commands/edit.rs @@ -38,6 +38,11 @@ pub fn style( state.run(move |e| api::style(e, color.as_deref(), opacity, width)) } +#[tauri::command(async)] +pub fn convert(state: State) -> api::Outcome { + state.run(api::convert) +} + #[tauri::command(async)] pub fn new_route(state: State) -> api::Outcome { state.run(api::new_route) diff --git a/tauri/src/lib.rs b/tauri/src/lib.rs index bd2037174..af9fbd76a 100644 --- a/tauri/src/lib.rs +++ b/tauri/src/lib.rs @@ -135,6 +135,7 @@ fn register_commands(builder: tauri::Builder) -> tauri::Bu // edits and tools edit::metadata, edit::style, + edit::convert, edit::new_route, edit::new_track, edit::new_track_segment, diff --git a/wasm/src/command/edit.rs b/wasm/src/command/edit.rs index 566f88ed3..123e7149f 100644 --- a/wasm/src/command/edit.rs +++ b/wasm/src/command/edit.rs @@ -18,6 +18,12 @@ pub fn style(color: Option, opacity: Option, width: Option) -> run(|e| api::style(e, color.as_deref(), opacity, width)) } +/// Converts the selected routes to tracks, or the selected tracks or segments to routes. +#[wasm_bindgen] +pub fn convert() -> Outcome { + run(api::convert) +} + #[wasm_bindgen] pub fn new_route() -> Outcome { run(api::new_route) diff --git a/website/src/lib/components/Menu.svelte b/website/src/lib/components/Menu.svelte index d934fc9e6..92df6e68c 100644 --- a/website/src/lib/components/Menu.svelte +++ b/website/src/lib/components/Menu.svelte @@ -48,6 +48,7 @@ Maximize2, Minimize2, FolderX, + ArrowRightLeft, } from '@lucide/svelte'; import { map } from '$lib/components/map/map'; import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte'; @@ -327,6 +328,19 @@ {i18n._('menu.new_segment')} {/if} + {#if $selection.type === 'route' || $selection.type === 'track' || $selection.type === 'segment'} + {#if $selection.type !== 'track'} + + {/if} + engine.convert()}> + + {i18n._( + $selection.type === 'route' + ? 'menu.convert_to_track' + : 'menu.convert_to_route' + )} + + {/if} {/if} engine.selectAll()} disabled={noFiles}> diff --git a/website/src/lib/components/file-list/FileListNodeLabel.svelte b/website/src/lib/components/file-list/FileListNodeLabel.svelte index d2a275ea8..c5e20d466 100644 --- a/website/src/lib/components/file-list/FileListNodeLabel.svelte +++ b/website/src/lib/components/file-list/FileListNodeLabel.svelte @@ -19,6 +19,7 @@ FileStack, FileX, Route, + ArrowRightLeft, } from '@lucide/svelte'; import { getContext, untrack } from 'svelte'; import { i18n } from '$lib/i18n/i18n.svelte'; @@ -289,6 +290,14 @@ {i18n._('menu.new_segment')} + {/if} + {#if isPath || node.type === 'segment'} + engine.convert()}> + + {i18n._( + node.type === 'route' ? 'menu.convert_to_track' : 'menu.convert_to_route' + )} + {/if} {/if} diff --git a/website/src/lib/engine/backend/tauri.ts b/website/src/lib/engine/backend/tauri.ts index 26dc296d8..4e8641f0f 100644 --- a/website/src/lib/engine/backend/tauri.ts +++ b/website/src/lib/engine/backend/tauri.ts @@ -83,6 +83,7 @@ export async function createTauriBackend(): Promise { }), metadata: (name, desc) => invoke('metadata', { name, desc }), style: (color, opacity, width) => invoke('style', { color, opacity, width }), + convert: () => invoke('convert'), new_route: () => invoke('new_route'), new_track: () => invoke('new_track'), new_track_segment: () => invoke('new_track_segment'), diff --git a/website/src/lib/engine/backend/types.ts b/website/src/lib/engine/backend/types.ts index b369c4284..a4e29d0b3 100644 --- a/website/src/lib/engine/backend/types.ts +++ b/website/src/lib/engine/backend/types.ts @@ -174,6 +174,7 @@ export interface Backend { move_elements(what: Selection, to: MoveTarget): Promise; metadata(name: string, desc: string): Promise; style(color?: string, opacity?: number, width?: number): Promise; + convert(): Promise; new_route(): Promise; new_track(): Promise; new_track_segment(): Promise; diff --git a/website/src/lib/engine/backend/wasm.ts b/website/src/lib/engine/backend/wasm.ts index dee24b7c3..26983237e 100644 --- a/website/src/lib/engine/backend/wasm.ts +++ b/website/src/lib/engine/backend/wasm.ts @@ -79,6 +79,7 @@ export async function createWasmBackend(): Promise { move_elements: lift(w.move_elements), metadata: lift(w.metadata), style: lift(w.style), + convert: lift(w.convert), new_route: lift(w.new_route), new_track: lift(w.new_track), new_track_segment: lift(w.new_track_segment), diff --git a/website/src/lib/engine/engine.ts b/website/src/lib/engine/engine.ts index 0a6313c07..bbdab92dc 100644 --- a/website/src/lib/engine/engine.ts +++ b/website/src/lib/engine/engine.ts @@ -365,6 +365,14 @@ class Engine { // Edits of the selection + /** + * Converts the selected routes to tracks, or the selected tracks or segments to routes (one + * per segment), which are selected. Resolves to `false` when there is nothing to convert. + */ + convert() { + return this.run((b) => b.convert()); + } + /** Adds an empty route to the selected files. */ newRoute() { return this.run((b) => b.new_route()); diff --git a/website/src/locales/en.json b/website/src/locales/en.json index 537221c12..32a513591 100644 --- a/website/src/locales/en.json +++ b/website/src/locales/en.json @@ -12,6 +12,8 @@ "new_file": "New file", "new_track": "New track", "new_route": "New route", + "convert_to_route": "Convert to route", + "convert_to_track": "Convert to track", "new_segment": "New segment", "open": "Open...", "open_folder": "Open folder...",