From c6bd5b06e8f86e97210aae83115ed0d4b043de7d Mon Sep 17 00:00:00 2001 From: vcoppe Date: Sun, 4 Oct 2026 20:38:46 +0200 Subject: [PATCH] clean tool --- .../engine/src/engine/command/tools/clean.rs | 393 +++++++++++++++++- .../lib/components/toolbar/tools/Clean.svelte | 24 +- website/src/lib/engine.ts | 23 + website/src/lib/logic/file-actions.ts | 51 --- 4 files changed, 422 insertions(+), 69 deletions(-) diff --git a/gpx-rs/engine/src/engine/command/tools/clean.rs b/gpx-rs/engine/src/engine/command/tools/clean.rs index 33c1d2adc..bc1f7325a 100644 --- a/gpx-rs/engine/src/engine/command/tools/clean.rs +++ b/gpx-rs/engine/src/engine/command/tools/clean.rs @@ -1,4 +1,10 @@ -use crate::{Apply, CommandError, LngLatBounds, State}; +use std::{collections::HashSet, rc::Rc}; + +use crate::{ + Apply, CommandError, Edit, Editor, File, FileId, LngLat, LngLatBounds, Selection, State, + TrackSegment, TrackSegmentId, Trackpoint, Waypoint, delete_waypoints, edit_waypoint_chunks, + update_selected, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CleanType { @@ -6,6 +12,9 @@ pub enum CleanType { Outside, } +/// Removes the trackpoints and/or waypoints of the selection lying inside (or outside) a +/// rectangle. A selected file cleans its tracks and its waypoints, a selected track or segment +/// only trackpoints, selected waypoints only waypoints. #[derive(Debug)] pub struct Clean { pub bounds: LngLatBounds, @@ -14,9 +23,385 @@ pub struct Clean { pub wpt: bool, } +impl Clean { + fn contains(&self, c: LngLat) -> bool { + (self.bounds.sw.lng..=self.bounds.ne.lng).contains(&c.lng) + && (self.bounds.sw.lat..=self.bounds.ne.lat).contains(&c.lat) + } + + fn removes(&self, c: LngLat) -> bool { + self.contains(c) == (self.type_ == CleanType::Inside) + } +} + impl Apply for Clean { - fn apply(self, _state: &mut State) -> Result<(), CommandError> { - // TODO - Err(CommandError::NotImplemented("clean")) + fn apply(self, state: &mut State) -> Result<(), CommandError> { + match &*state.selection { + Selection::Waypoint { file_id, .. } | Selection::Waypoints { file_id } => { + let file_id = *file_id; + if !self.wpt { + return Err(CommandError::NothingToDo); + } + let wpt_ids = match &*state.selection { + Selection::Waypoint { wpt_ids, .. } => Some(wpt_ids.clone()), + _ => None, + }; + let selected = + |wpt: &Waypoint| wpt_ids.as_ref().is_none_or(|ids| ids.contains(&wpt.id)); + *state.selection = delete_waypoints(state.files, file_id, |wpt| { + selected(wpt) && self.removes(wpt.coordinates) + })?; + Ok(()) + } + Selection::Empty => Err(CommandError::NothingToDo), + _ => { + let mut cleaner = Cleaner { + clean: &self, + changed: false, + emptied: HashSet::new(), + wpt_files: HashSet::new(), + }; + update_selected(state, &mut cleaner); + if cleaner.changed { + prune(state, &cleaner.emptied, &cleaner.wpt_files); + Ok(()) + } else { + Err(CommandError::NothingToDo) + } + } + } + } +} + +struct Cleaner<'a> { + clean: &'a Clean, + changed: bool, + /// Segments left without trackpoints by the clean. + emptied: HashSet, + /// Files that lost waypoints. + wpt_files: HashSet, +} + +impl Editor for Cleaner<'_> { + fn file(&mut self, file: &mut File) -> Edit { + let mut edit = Edit::Unchanged; + for trk in &mut file.trk { + if self.track(trk) == Edit::Changed { + edit = Edit::Changed; + } + } + if self.clean.wpt + && edit_waypoint_chunks( + file, + |wpt| self.clean.removes(wpt.coordinates), + |wpts| { + wpts.retain(|wpt| !self.clean.removes(wpt.coordinates)); + true + }, + ) + { + edit = Edit::Changed; + self.wpt_files.insert(file.id); + } + self.changed |= edit == Edit::Changed; + edit + } + + fn segment(&mut self, segment: &mut TrackSegment) -> Edit { + if !self.clean.trkpt || !segment.iter().any(|pt| self.clean.removes(pt.coordinates)) { + return Edit::Unchanged; + } + let kept: Vec = segment + .iter() + .filter(|pt| !self.clean.removes(pt.coordinates)) + .cloned() + .collect(); + if kept.is_empty() { + self.emptied.insert(segment.id); + } + segment.splice(0, segment.len(), kept); + self.changed = true; + Edit::Changed + } +} + +/// Removes what the clean left empty: the emptied segments, the tracks that lost all their +/// segments and the files that lost something and have nothing left. The selection falls back +/// to the closest element still existing. +fn prune(state: &mut State, emptied: &HashSet, wpt_files: &HashSet) { + let ids: Vec = state + .files + .iter() + .filter(|(id, file)| { + wpt_files.contains(id) + || file + .trk + .iter() + .any(|trk| trk.trkseg.iter().any(|seg| emptied.contains(&seg.id))) + }) + .map(|(id, _)| *id) + .collect(); + for id in ids { + let mut file = (*state.files[&id]).clone(); + let lost_something = !emptied.is_empty() || wpt_files.contains(&id); + file.trk.retain_mut(|trk| { + let len = trk.trkseg.len(); + trk.trkseg.retain(|seg| !emptied.contains(&seg.id)); + trk.trkseg.len() == len || !trk.trkseg.is_empty() + }); + if lost_something && file.trk.is_empty() && file.wpt.is_empty() { + state.files.remove(&id); + state.order.0.retain(|f| *f != id); + } else { + state.files.insert(id, Rc::new(file)); + } + } + *state.selection = fixed_selection(state); +} + +fn fixed_selection(state: &State) -> Selection { + let files = &*state.files; + match &*state.selection { + Selection::File { file_ids } => { + let file_ids: HashSet<_> = file_ids + .iter() + .filter(|id| files.contains_key(id)) + .copied() + .collect(); + if file_ids.is_empty() { + Selection::Empty + } else { + Selection::File { file_ids } + } + } + Selection::Track { file_id, trk_ids } => { + let Some(file) = files.get(file_id) else { + return Selection::Empty; + }; + let trk_ids: HashSet<_> = file + .trk + .iter() + .map(|trk| trk.id) + .filter(|id| trk_ids.contains(id)) + .collect(); + if trk_ids.is_empty() { + Selection::File { + file_ids: [*file_id].into(), + } + } else { + Selection::Track { + file_id: *file_id, + trk_ids, + } + } + } + Selection::TrackSegment { + file_id, + trk_id, + trkseg_ids, + } => { + let Some(file) = files.get(file_id) else { + return Selection::Empty; + }; + let Some(trk) = file.trk.iter().find(|trk| trk.id == *trk_id) else { + return Selection::File { + file_ids: [*file_id].into(), + }; + }; + let trkseg_ids: HashSet<_> = trk + .trkseg + .iter() + .map(|seg| seg.id) + .filter(|id| trkseg_ids.contains(id)) + .collect(); + if trkseg_ids.is_empty() { + Selection::Track { + file_id: *file_id, + trk_ids: [*trk_id].into(), + } + } else { + Selection::TrackSegment { + file_id: *file_id, + trk_id: *trk_id, + trkseg_ids, + } + } + } + Selection::Waypoints { file_id } | Selection::Waypoint { file_id, .. } => { + if files.contains_key(file_id) { + Selection::File { + file_ids: [*file_id].into(), + } + } else { + Selection::Empty + } + } + Selection::Empty => Selection::Empty, + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use crate::{FileId, Load, engine::command::fixture::Fixture}; + + use super::*; + + fn loaded() -> (Fixture, FileId) { + let mut fx = Fixture::default(); + let data = std::fs::read("data/with_tracks_and_segments.gpx").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 bounds(w: f64, s: f64, e: f64, n: f64) -> LngLatBounds { + LngLatBounds { + sw: LngLat { lng: w, lat: s }, + ne: LngLat { lng: e, lat: n }, + } + } + + fn clean(bounds: LngLatBounds, type_: CleanType) -> Clean { + Clean { + bounds, + type_, + trkpt: true, + wpt: true, + } + } + + fn count(fx: &Fixture, id: FileId) -> usize { + fx.files + .get(&id) + .into_iter() + .flat_map(|file| &file.trk) + .flat_map(|trk| &trk.trkseg) + .map(|seg| seg.len()) + .sum() + } + + #[test] + fn test_inside_everything_removes_all_trackpoints() { + let (mut fx, id) = loaded(); + assert!(count(&fx, id) > 0); + clean(bounds(-180.0, -90.0, 180.0, 90.0), CleanType::Inside) + .apply(&mut fx.state()) + .unwrap(); + assert_eq!(count(&fx, id), 0); + // nothing is left of the file + assert!(!fx.files.contains_key(&id)); + assert!(fx.order.0.is_empty()); + assert_eq!(fx.selection, Selection::Empty); + } + + #[test] + fn test_emptied_segments_and_tracks_are_removed() { + let (mut fx, id) = loaded(); + let before = fx.files[&id].clone(); + let seg = &before.trk[0].trkseg[0]; + let pts: Vec<_> = seg.iter().map(|p| p.coordinates).collect(); + let mut b = LngLatBounds::default(); + pts.iter().for_each(|c| b.extend(*c)); + clean(b, CleanType::Inside).apply(&mut fx.state()).unwrap(); + let after = &fx.files[&id]; + assert!(after.trk.iter().all(|t| !t.trkseg.is_empty())); + assert!( + after + .trk + .iter() + .flat_map(|t| &t.trkseg) + .all(|s| !s.is_empty()) + ); + } + + #[test] + fn test_outside_everything_changes_nothing() { + let (mut fx, id) = loaded(); + let before = fx.files[&id].clone(); + let result = + clean(bounds(-180.0, -90.0, 180.0, 90.0), CleanType::Outside).apply(&mut fx.state()); + assert!(matches!(result, Err(CommandError::NothingToDo))); + assert_eq!(fx.files[&id], before); + } + + #[test] + fn test_partial_clean_only_removes_points_inside_and_bumps_revision() { + let (mut fx, id) = loaded(); + let before = fx.files[&id].clone(); + let first = before.trk[0].trkseg[0].iter().next().unwrap().coordinates; + let total = count(&fx, id); + // tiny rectangle around the first point + let b = bounds( + first.lng - 1e-9, + first.lat - 1e-9, + first.lng + 1e-9, + first.lat + 1e-9, + ); + clean(b, CleanType::Inside).apply(&mut fx.state()).unwrap(); + let removed = total - count(&fx, id); + assert!(removed >= 1); + let seg = &fx.files[&id].trk[0].trkseg[0]; + assert_ne!(seg.rev_id, before.trk[0].trkseg[0].rev_id); + assert!(seg.iter().all(|pt| !b_contains(&b, pt.coordinates))); + } + + fn b_contains(b: &LngLatBounds, c: LngLat) -> bool { + c.lng >= b.sw.lng && c.lng <= b.ne.lng && c.lat >= b.sw.lat && c.lat <= b.ne.lat + } + + #[test] + fn test_waypoints_flag_and_selection() { + let (mut fx, id) = loaded(); + let mut file = (*fx.files[&id]).clone(); + file.wpt = vec![std::rc::Rc::new(crate::WaypointChunk { + wpt: vec![Waypoint::default(), Waypoint::default()], + ..Default::default() + })]; + let ids: Vec<_> = file.wpt[0].wpt.iter().map(|w| w.id).collect(); + fx.files.insert(id, std::rc::Rc::new(file)); + let everywhere = bounds(-180.0, -90.0, 180.0, 90.0); + let wpt_count = |fx: &Fixture| { + fx.files + .get(&id) + .map_or(0, |f| f.wpt.iter().map(|c| c.wpt.len()).sum::()) + }; + + // trackpoints only: waypoints are kept + Clean { + wpt: false, + ..clean(everywhere, CleanType::Inside) + } + .apply(&mut fx.state()) + .unwrap(); + assert_eq!(wpt_count(&fx), 2); + + // one selected waypoint + fx.selection = Selection::Waypoint { + file_id: id, + wpt_ids: HashSet::from([ids[0]]), + }; + clean(everywhere, CleanType::Inside) + .apply(&mut fx.state()) + .unwrap(); + assert_eq!(wpt_count(&fx), 1); + + // whole file + fx.selection = Selection::File { + file_ids: HashSet::from([id]), + }; + clean(everywhere, CleanType::Inside) + .apply(&mut fx.state()) + .unwrap(); + assert_eq!(wpt_count(&fx), 0); } } diff --git a/website/src/lib/components/toolbar/tools/Clean.svelte b/website/src/lib/components/toolbar/tools/Clean.svelte index 234b633c6..9ad18841f 100644 --- a/website/src/lib/components/toolbar/tools/Clean.svelte +++ b/website/src/lib/components/toolbar/tools/Clean.svelte @@ -17,8 +17,7 @@ import { Trash2 } from '@lucide/svelte'; import { map } from '$lib/components/map/map'; import type { GeoJSONSource } from 'maplibre-gl'; - import { selection } from '$lib/logic/selection'; - import { fileActions } from '$lib/logic/file-actions'; + import { engine } from '$lib/engine'; import { mapCursor, MapCursorState } from '$lib/logic/map-cursor'; import { ANCHOR_LAYER_KEY } from '$lib/components/map/style'; @@ -130,7 +129,8 @@ } }); - let validSelection = $derived($selection.size > 0); + const selection = engine.selection; + let validSelection = $derived($selection.type !== 'empty');
@@ -163,17 +163,13 @@ class="w-full" disabled={!validSelection || rectangleCoordinates.length != 2} onclick={() => { - fileActions.cleanSelection( - [ - { - lat: Math.min(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat), - lon: Math.min(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng), - }, - { - lat: Math.max(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat), - lon: Math.max(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng), - }, - ], + engine.clean( + { + west: Math.min(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng), + south: Math.min(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat), + east: Math.max(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng), + north: Math.max(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat), + }, cleanType === CleanType.INSIDE, deleteTrackpoints, deleteWaypoints diff --git a/website/src/lib/engine.ts b/website/src/lib/engine.ts index 18066ef7a..1ff3bc5fb 100644 --- a/website/src/lib/engine.ts +++ b/website/src/lib/engine.ts @@ -494,6 +494,29 @@ class Engine { return this.run((w) => w.delete_waypoint(fileId, waypointId)); } + /** + * Removes the trackpoints and/or waypoints of the selection inside (or outside) a rectangle. + * Resolves to `false` when nothing was removed. + */ + clean( + bounds: { west: number; south: number; east: number; north: number }, + inside: boolean, + trackpoints: boolean, + waypoints: boolean + ) { + return this.run((w) => + w.clean( + bounds.west, + bounds.south, + bounds.east, + bounds.north, + inside ? w.CleanType.Inside : w.CleanType.Outside, + trackpoints, + waypoints + ) + ); + } + /** A trackpoint of a segment, `undefined` if it does not exist. */ trackpoint(fileId: string, segmentId: string, index: number): TrackpointDetails | undefined { return this.wasm?.trackpoint(fileId, segmentId, index); diff --git a/website/src/lib/logic/file-actions.ts b/website/src/lib/logic/file-actions.ts index 57a6940cd..d2516369a 100644 --- a/website/src/lib/logic/file-actions.ts +++ b/website/src/lib/logic/file-actions.ts @@ -570,57 +570,6 @@ export const fileActions = { } }); }, - cleanSelection: ( - bounds: [Coordinates, Coordinates], - inside: boolean, - deleteTrackPoints: boolean, - deleteWaypoints: boolean - ) => { - if (get(selection).size === 0) { - return; - } - fileActionManager.applyGlobal((draft) => { - selection.applyToOrderedSelectedItemsFromFile((fileId, level, items) => { - let file = draft.get(fileId); - if (file) { - if (level === ListLevel.FILE) { - file.clean(bounds, inside, deleteTrackPoints, deleteWaypoints); - } else if (level === ListLevel.TRACK) { - let trackIndices = items.map((item) => - (item as ListTrackItem).getTrackIndex() - ); - file.clean( - bounds, - inside, - deleteTrackPoints, - deleteWaypoints, - trackIndices - ); - } else if (level === ListLevel.SEGMENT) { - let trackIndices = [(items[0] as ListTrackSegmentItem).getTrackIndex()]; - let segmentIndices = items.map((item) => - (item as ListTrackSegmentItem).getSegmentIndex() - ); - file.clean( - bounds, - inside, - deleteTrackPoints, - deleteWaypoints, - trackIndices, - segmentIndices - ); - } else if (level === ListLevel.WAYPOINTS) { - file.clean(bounds, inside, false, deleteWaypoints); - } else if (level === ListLevel.WAYPOINT) { - let waypointIndices = items.map((item) => - (item as ListWaypointItem).getWaypointIndex() - ); - file.clean(bounds, inside, false, deleteWaypoints, [], [], waypointIndices); - } - } - }); - }); - }, reduce: (itemsAndPoints: Map) => { if (itemsAndPoints.size === 0) { return;