mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-06 22:04:06 +00:00
clean tool
This commit is contained in:
@@ -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<TrackSegmentId>,
|
||||
/// Files that lost waypoints.
|
||||
wpt_files: HashSet<FileId>,
|
||||
}
|
||||
|
||||
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<Trackpoint> = 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<TrackSegmentId>, wpt_files: &HashSet<FileId>) {
|
||||
let ids: Vec<FileId> = 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::<usize>())
|
||||
};
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3 w-full max-w-80 items-center {props.class ?? ''}">
|
||||
@@ -163,17 +163,13 @@
|
||||
class="w-full"
|
||||
disabled={!validSelection || rectangleCoordinates.length != 2}
|
||||
onclick={() => {
|
||||
fileActions.cleanSelection(
|
||||
[
|
||||
engine.clean(
|
||||
{
|
||||
lat: Math.min(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat),
|
||||
lon: Math.min(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng),
|
||||
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),
|
||||
},
|
||||
{
|
||||
lat: Math.max(rectangleCoordinates[0].lat, rectangleCoordinates[1].lat),
|
||||
lon: Math.max(rectangleCoordinates[0].lng, rectangleCoordinates[1].lng),
|
||||
},
|
||||
],
|
||||
cleanType === CleanType.INSIDE,
|
||||
deleteTrackpoints,
|
||||
deleteWaypoints
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<ListItem, TrackPoint[]>) => {
|
||||
if (itemsAndPoints.size === 0) {
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user