osm edits

This commit is contained in:
2026-04-27 16:03:21 +05:30
parent 71c6798e53
commit 1f70e01d89
8 changed files with 1594 additions and 16 deletions

View File

@@ -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.510 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,

View File

@@ -5,10 +5,11 @@ mod scope;
use commands::{
delete_asset, delete_assets_by_video, get_assets_in_bbox, get_osm_roads,
get_scope_polygons, import_fixed_assets, import_kml_scope, import_osm_geojson,
import_range_assets, list_assets, list_recent_actions, merge_polylines, reset_assets_to_original,
import_range_assets, list_assets, read_video_polylines_json, list_recent_actions, merge_polylines, reset_assets_to_original,
read_metadata_polyline, redo_action, reset_database, snap_assets_in_bbox,
add_class, auto_link_in_polygon, bulk_translate, change_assets_side, clear_link, clear_manual_scope,
create_osm_road, create_user, set_link,
add_class, auto_link_in_polygon, bulk_translate, clear_assets_by_type,
clear_osm_roads, clear_scope_polygons, data_source_counts, change_assets_side, clear_link, clear_manual_scope,
create_osm_road, create_user, delete_road_vertices, set_link, simplify_road,
delete_roads_by_highway, export_osm_roads, find_duplicate_clusters,
flip_road_direction, generate_osm_for_bbox, list_road_classes, merge_roads_by_name,
delete_assets_bulk, delete_osm_road, delete_user, export_assets,
@@ -47,6 +48,7 @@ pub fn run() {
get_assets_in_bbox,
update_asset_position,
import_kml_scope,
read_video_polylines_json,
get_scope_polygons,
delete_asset,
delete_assets_by_video,
@@ -62,6 +64,8 @@ pub fn run() {
snap_assets_in_bbox,
update_osm_road,
create_osm_road,
delete_road_vertices,
simplify_road,
delete_osm_road,
list_asset_names,
export_assets,
@@ -88,6 +92,10 @@ pub fn run() {
change_assets_side,
auto_link_in_polygon,
bulk_translate,
clear_assets_by_type,
clear_osm_roads,
clear_scope_polygons,
data_source_counts,
set_link,
clear_link
])

View File

@@ -164,7 +164,6 @@ pub async fn apply_scope_to_assets(
geoms: &[ScopeGeom],
) -> Result<usize> {
if geoms.is_empty() {
// Reset only assets that don't have a manual override.
sqlx::query("UPDATE assets SET in_scope = NULL WHERE manual_scope IS NULL")
.execute(pool)
.await?;