osm edits
This commit is contained in:
@@ -426,6 +426,78 @@ pub async fn import_kml_scope(
|
||||
Ok(parsed.len())
|
||||
}
|
||||
|
||||
/// Read a multi-video metadata polyline file shaped as
|
||||
/// `{ video_name: [[[lat, lng], ...]] }`. Returns a map from video_name to a
|
||||
/// `[lng, lat]` track (deck.gl / GeoJSON convention).
|
||||
#[tauri::command]
|
||||
pub async fn read_video_polylines_json(
|
||||
path: String,
|
||||
) -> Result<std::collections::HashMap<String, Vec<[f64; 2]>>, String> {
|
||||
let bytes = tokio::fs::read(&path).await.map_err(|e| e.to_string())?;
|
||||
let parsed: serde_json::Map<String, serde_json::Value> =
|
||||
serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON: {}", e))?;
|
||||
let mut out: std::collections::HashMap<String, Vec<[f64; 2]>> =
|
||||
std::collections::HashMap::new();
|
||||
for (video_name, val) in parsed {
|
||||
let outer = match val.as_array() {
|
||||
Some(a) if !a.is_empty() => a,
|
||||
_ => continue,
|
||||
};
|
||||
let pts_json = match outer[0].as_array() {
|
||||
Some(a) => a,
|
||||
None => continue,
|
||||
};
|
||||
let mut track: Vec<[f64; 2]> = Vec::with_capacity(pts_json.len());
|
||||
for pt in pts_json {
|
||||
let arr = match pt.as_array() {
|
||||
Some(a) if a.len() >= 2 => a,
|
||||
_ => continue,
|
||||
};
|
||||
// Source is [lat, lng]; downstream code expects [lng, lat].
|
||||
let lat = arr[0].as_f64();
|
||||
let lng = arr[1].as_f64();
|
||||
if let (Some(la), Some(lo)) = (lat, lng) {
|
||||
// Drop sentinel / garbage coords. Anything within ~100 m of
|
||||
// the (0,0) origin is the standard "no GPS fix" placeholder.
|
||||
if la.abs() < 1e-3 && lo.abs() < 1e-3 {
|
||||
continue;
|
||||
}
|
||||
if !la.is_finite() || !lo.is_finite() {
|
||||
continue;
|
||||
}
|
||||
if !(-90.0..=90.0).contains(&la) || !(-180.0..=180.0).contains(&lo) {
|
||||
continue;
|
||||
}
|
||||
// Break implausible jumps (> ~5 km between consecutive points).
|
||||
// Most consumer GPS tracks sample faster than that; a long jump
|
||||
// signals a missing point, so skip it rather than draw a line
|
||||
// across the map.
|
||||
if let Some(&[plng, plat]) = track.last() {
|
||||
let dlat = la - plat;
|
||||
let dlng = lo - plng;
|
||||
// Cheap planar approximation in degrees → meters.
|
||||
let m_per_deg_lat = 111_000.0;
|
||||
let m_per_deg_lng = 111_000.0 * la.to_radians().cos().max(0.1);
|
||||
let dist = ((dlat * m_per_deg_lat).powi(2)
|
||||
+ (dlng * m_per_deg_lng).powi(2))
|
||||
.sqrt();
|
||||
if dist > 5_000.0 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
track.push([lo, la]);
|
||||
}
|
||||
}
|
||||
if track.len() >= 2 {
|
||||
out.insert(video_name, track);
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err("no usable polylines in file".to_string());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_scope_polygons(
|
||||
state: State<'_, AppState>,
|
||||
@@ -3315,6 +3387,113 @@ pub async fn delete_osm_road(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a list of vertex indices from a road's polyline. Indices are
|
||||
/// zero-based and refer to the *current* coords array. The road must keep at
|
||||
/// least 2 vertices afterwards.
|
||||
#[tauri::command]
|
||||
pub async fn delete_road_vertices(
|
||||
id: i64,
|
||||
indices: Vec<usize>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<usize, String> {
|
||||
let row: (String,) = sqlx::query_as("SELECT geojson FROM roads WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let val: serde_json::Value =
|
||||
serde_json::from_str(&row.0).map_err(|e| e.to_string())?;
|
||||
let arr = val
|
||||
.get("coordinates")
|
||||
.and_then(|c| c.as_array())
|
||||
.ok_or_else(|| "road has no coordinates".to_string())?;
|
||||
let mut coords: Vec<[f64; 2]> = arr
|
||||
.iter()
|
||||
.filter_map(|p| p.as_array())
|
||||
.filter_map(|p| {
|
||||
Some([p.first()?.as_f64()?, p.get(1)?.as_f64()?])
|
||||
})
|
||||
.collect();
|
||||
use std::collections::HashSet;
|
||||
let drop: HashSet<usize> = indices.into_iter().collect();
|
||||
let new_coords: Vec<[f64; 2]> = coords
|
||||
.drain(..)
|
||||
.enumerate()
|
||||
.filter(|(i, _)| !drop.contains(i))
|
||||
.map(|(_, c)| c)
|
||||
.collect();
|
||||
if new_coords.len() < 2 {
|
||||
return Err(
|
||||
"removing those vertices would leave < 2 — road needs at least 2".into(),
|
||||
);
|
||||
}
|
||||
let removed = drop.len();
|
||||
update_osm_road(id, new_coords, state).await?;
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// Douglas-Peucker simplification of a road's polyline. Tolerance is in metres.
|
||||
/// Converted to a degrees epsilon at the road's centroid latitude — accurate
|
||||
/// enough for cleanup tolerances of 0.5–10 m.
|
||||
#[tauri::command]
|
||||
pub async fn simplify_road(
|
||||
id: i64,
|
||||
tolerance_m: f64,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(usize, usize), String> {
|
||||
if tolerance_m <= 0.0 {
|
||||
return Err("tolerance must be > 0".into());
|
||||
}
|
||||
let row: (String,) = sqlx::query_as("SELECT geojson FROM roads WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let val: serde_json::Value =
|
||||
serde_json::from_str(&row.0).map_err(|e| e.to_string())?;
|
||||
let arr = val
|
||||
.get("coordinates")
|
||||
.and_then(|c| c.as_array())
|
||||
.ok_or_else(|| "road has no coordinates".to_string())?;
|
||||
let coords: Vec<[f64; 2]> = arr
|
||||
.iter()
|
||||
.filter_map(|p| p.as_array())
|
||||
.filter_map(|p| Some([p.first()?.as_f64()?, p.get(1)?.as_f64()?]))
|
||||
.collect();
|
||||
let before = coords.len();
|
||||
if before < 3 {
|
||||
return Ok((before, before));
|
||||
}
|
||||
// Convert tolerance metres → degrees, with a cos-lat correction for lng.
|
||||
let mid_lat = coords[before / 2][1];
|
||||
let m_per_deg_lat = 111_000.0_f64;
|
||||
let m_per_deg_lng = 111_000.0_f64 * mid_lat.to_radians().cos().abs().max(0.1);
|
||||
// Project to a local "metres" space so DP epsilon is uniform.
|
||||
let projected: Vec<[f64; 2]> = coords
|
||||
.iter()
|
||||
.map(|c| [c[0] * m_per_deg_lng, c[1] * m_per_deg_lat])
|
||||
.collect();
|
||||
use geo::algorithm::Simplify;
|
||||
let line = LineString::from(
|
||||
projected
|
||||
.iter()
|
||||
.map(|c| (c[0], c[1]))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
let simplified = line.simplify(tolerance_m);
|
||||
let new_coords: Vec<[f64; 2]> = simplified
|
||||
.0
|
||||
.iter()
|
||||
.map(|c| [c.x / m_per_deg_lng, c.y / m_per_deg_lat])
|
||||
.collect();
|
||||
if new_coords.len() < 2 {
|
||||
return Err("simplification would leave < 2 vertices".into());
|
||||
}
|
||||
let after = new_coords.len();
|
||||
update_osm_road(id, new_coords, state).await?;
|
||||
Ok((before, after))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_osm_road(
|
||||
id: i64,
|
||||
@@ -3986,6 +4165,87 @@ pub async fn reset_database(state: State<'_, AppState>) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DataSourceCounts {
|
||||
pub fixed_assets: i64,
|
||||
pub range_assets: i64,
|
||||
pub osm_roads: i64,
|
||||
pub scope_features: i64,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn data_source_counts(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<DataSourceCounts, String> {
|
||||
let (fixed,): (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM assets WHERE asset_type = 'fixed' AND deleted = 0")
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (range,): (i64,) =
|
||||
sqlx::query_as("SELECT COUNT(*) FROM assets WHERE asset_type = 'range' AND deleted = 0")
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (roads,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM roads")
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (scope,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM scope_polygons")
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(DataSourceCounts {
|
||||
fixed_assets: fixed,
|
||||
range_assets: range,
|
||||
osm_roads: roads,
|
||||
scope_features: scope,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clear_assets_by_type(
|
||||
asset_type: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<usize, String> {
|
||||
if asset_type != "fixed" && asset_type != "range" {
|
||||
return Err("asset_type must be 'fixed' or 'range'".into());
|
||||
}
|
||||
let res = sqlx::query("DELETE FROM assets WHERE asset_type = ?")
|
||||
.bind(&asset_type)
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(res.rows_affected() as usize)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clear_osm_roads(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<usize, String> {
|
||||
let res = sqlx::query("DELETE FROM roads")
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(res.rows_affected() as usize)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clear_scope_polygons(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<usize, String> {
|
||||
let res = sqlx::query("DELETE FROM scope_polygons")
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
// Reset derived in_scope on every asset that didn't have a manual override.
|
||||
sqlx::query("UPDATE assets SET in_scope = NULL WHERE manual_scope IS NULL")
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(res.rows_affected() as usize)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_recent_actions(
|
||||
limit: i64,
|
||||
|
||||
Reference in New Issue
Block a user