From 2660ef12b922c54cab2c7f63ebcc920d6f01848f Mon Sep 17 00:00:00 2001 From: ravi Date: Fri, 8 May 2026 18:28:07 +0530 Subject: [PATCH] linear bug and osm new features --- src-tauri/src/commands.rs | 481 +++++++++++++++++++++++++++++++++++++- src-tauri/src/lib.rs | 5 +- src/App.tsx | 178 +++++++++++++- src/MapView.tsx | 140 ++++++++++- 4 files changed, 781 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index bb2c7bb..0bc18e3 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -66,8 +66,14 @@ struct RangeAssetInput { row_id: String, asset_name: String, video_name: String, - coord_lat: NumOrStr, - coord_lng: NumOrStr, + // Midpoint is optional: real data sometimes ships only start+end (no + // mid-frame annotated). When absent we synthesize (start+end)/2 below so + // the line still renders; the popup gates the M pin on image_path2 so a + // synthesized midpoint never shows as a third pin. + #[serde(default)] + coord_lat: Option, + #[serde(default)] + coord_lng: Option, start_coord_lat: NumOrStr, start_coord_lng: NumOrStr, end_coord_lat: NumOrStr, @@ -81,6 +87,19 @@ struct RangeAssetInput { side: Option, } +fn opt_num_or_str_to_f64(v: &Option) -> Option { + match v.as_ref()? { + NumOrStr::Num(n) => Some(*n).filter(|x| x.is_finite()), + NumOrStr::Str(s) => { + let t = s.trim(); + if t.is_empty() { + return None; + } + t.parse::().ok().filter(|x| x.is_finite()) + } + } +} + use std::sync::{OnceLock, RwLock}; static CURRENT_USER: OnceLock> = OnceLock::new(); fn user_id() -> String { @@ -262,12 +281,16 @@ async fn insert_range_row( tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, input: &RangeAssetInput, ) -> Result<(), String> { - let mid_lat = input.coord_lat.to_f64()?; - let mid_lng = input.coord_lng.to_f64()?; let s_lat = input.start_coord_lat.to_f64()?; let s_lng = input.start_coord_lng.to_f64()?; let e_lat = input.end_coord_lat.to_f64()?; let e_lng = input.end_coord_lng.to_f64()?; + // Synthesize midpoint when missing/empty so the line still renders. + // Whether the midpoint is real is communicated to the UI via image_path2: + // popup gates the M pin on that, so a synthesized mid never shows as a + // third pin. + let mid_lat = opt_num_or_str_to_f64(&input.coord_lat).unwrap_or((s_lat + e_lat) / 2.0); + let mid_lng = opt_num_or_str_to_f64(&input.coord_lng).unwrap_or((s_lng + e_lng) / 2.0); let min_lat = mid_lat.min(s_lat).min(e_lat); let max_lat = mid_lat.max(s_lat).max(e_lat); let min_lng = mid_lng.min(s_lng).min(e_lng); @@ -437,13 +460,29 @@ pub async fn import_assets_auto( let mut fixed_count = 0usize; let mut range_count = 0usize; let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; + // Tighter range detection: presence of `start_coord_*` / `end_coord_*` + // keys isn't enough — many pipelines emit a SHARED schema where those + // fields exist on every row but are empty strings on fixed assets + // ("start_coord_lat": ""). Treat the row as range only when all four + // start/end coord fields parse to a finite number. + fn coord_field_valid(v: Option<&serde_json::Value>) -> bool { + match v { + Some(serde_json::Value::Number(n)) => { + n.as_f64().map(|x| x.is_finite()).unwrap_or(false) + } + Some(serde_json::Value::String(s)) => { + let t = s.trim(); + !t.is_empty() + && t.parse::().map(|x| x.is_finite()).unwrap_or(false) + } + _ => false, + } + } for row in rows { - // Range assets carry start_coord_* and end_coord_* fields. Use both - // to disambiguate from fixed (which has only `coord`). A row that - // also carries `coord` is fine — RangeAssetInput's `coord_lat` / - // `coord_lng` capture mid; the `coord` field is just ignored. - let is_range = row.get("start_coord_lat").is_some() - && row.get("end_coord_lat").is_some(); + let is_range = coord_field_valid(row.get("start_coord_lat")) + && coord_field_valid(row.get("start_coord_lng")) + && coord_field_valid(row.get("end_coord_lat")) + && coord_field_valid(row.get("end_coord_lng")); if is_range { let input: RangeAssetInput = serde_json::from_value(row).map_err(|e| e.to_string())?; @@ -4382,6 +4421,428 @@ pub async fn delete_road_vertices( Ok(removed) } +fn coords_bbox(coords: &[[f64; 2]]) -> (f64, f64, f64, f64) { + let mut mn_lat = f64::INFINITY; + let mut mx_lat = f64::NEG_INFINITY; + let mut mn_lng = f64::INFINITY; + let mut mx_lng = f64::NEG_INFINITY; + for &[lng, lat] in coords { + if lat < mn_lat { mn_lat = lat; } + if lat > mx_lat { mx_lat = lat; } + if lng < mn_lng { mn_lng = lng; } + if lng > mx_lng { mx_lng = lng; } + } + (mn_lat, mx_lat, mn_lng, mx_lng) +} + +/// Delete the section of a road that lies between two (or more) marked +/// vertices. Behaviour: +/// - sort the indices, take min and max +/// - drop everything strictly between them; keep [0..=min] (left) and +/// [max..end] (right) as the survivors +/// - if both halves have ≥2 vertices → split into TWO road rows +/// (original updated to left, new row inserted for right with the same +/// name / oneway / highway / snap_ignored carried over) +/// - if only one half has ≥2 vertices → original is shrunk to that half +/// - if neither half has ≥2 vertices → error +/// Returns the ids of the resulting road rows (1 or 2). +#[tauri::command] +pub async fn delete_road_section( + id: i64, + indices: Vec, + state: State<'_, AppState>, +) -> Result, String> { + if indices.len() < 2 { + return Err("need at least 2 marked vertices to define a section".into()); + } + let row: (Option, String, i64, Option, i64) = sqlx::query_as( + "SELECT name, geojson, COALESCE(oneway, 0), highway, + COALESCE(snap_ignored, 0) + FROM roads WHERE id = ?", + ) + .bind(id) + .fetch_one(&state.pool) + .await + .map_err(|e| e.to_string())?; + let (name, geojson, oneway, highway, snap_ignored) = row; + + let val: serde_json::Value = serde_json::from_str(&geojson) + .map_err(|e| format!("invalid geojson: {}", e))?; + 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(); + if coords.len() < 2 { + return Err("road has < 2 coordinates".into()); + } + + let mut sorted = indices; + sorted.sort_unstable(); + sorted.dedup(); + if sorted.len() < 2 { + return Err("need at least 2 distinct marked vertices".into()); + } + let min_i = sorted[0]; + let max_i = *sorted.last().unwrap(); + if max_i >= coords.len() { + return Err(format!( + "vertex index {} out of range (road has {} vertices)", + max_i, coords.len() + )); + } + // Adjacent marks (max_i == min_i + 1) sever the single edge between them: + // left keeps [..=min_i], right starts at max_i — both halves still hold both + // marked vertices, just the connecting edge is dropped. Non-adjacent marks + // additionally drop the strictly-between vertices. + let left: Vec<[f64; 2]> = coords[0..=min_i].to_vec(); + let right: Vec<[f64; 2]> = coords[max_i..].to_vec(); + let left_ok = left.len() >= 2; + let right_ok = right.len() >= 2; + if !left_ok && !right_ok { + return Err( + "section deletion would leave < 2 vertices on both sides".into(), + ); + } + + let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; + let mut result_ids: Vec = Vec::new(); + + // Helper: shrink the original road to the given coords, fix r-tree. + async fn shrink( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + id: i64, + coords: &[[f64; 2]], + ) -> Result<(), String> { + let (mn_lat, mx_lat, mn_lng, mx_lng) = coords_bbox(coords); + let geom = serde_json::json!({ + "type": "LineString", + "coordinates": coords, + }); + sqlx::query( + "UPDATE roads SET geojson = ?, min_lat = ?, max_lat = ?, + min_lng = ?, max_lng = ? + WHERE id = ?", + ) + .bind(geom.to_string()) + .bind(mn_lat).bind(mx_lat).bind(mn_lng).bind(mx_lng) + .bind(id) + .execute(&mut **tx) + .await + .map_err(|e| e.to_string())?; + // R-tree has only INSERT/DELETE triggers; emulate UPDATE. + sqlx::query("DELETE FROM roads_rtree WHERE id = ?") + .bind(id) + .execute(&mut **tx) + .await + .map_err(|e| e.to_string())?; + sqlx::query( + "INSERT INTO roads_rtree(id, min_lat, max_lat, min_lng, max_lng) + VALUES (?, ?, ?, ?, ?)", + ) + .bind(id).bind(mn_lat).bind(mx_lat).bind(mn_lng).bind(mx_lng) + .execute(&mut **tx) + .await + .map_err(|e| e.to_string())?; + Ok(()) + } + + if left_ok && right_ok { + // Split: original keeps the left half; right half becomes a new row + // carrying the same name/oneway/highway/snap_ignored. + shrink(&mut tx, id, &left).await?; + result_ids.push(id); + let (rn_lat, rx_lat, rn_lng, rx_lng) = coords_bbox(&right); + let geom = serde_json::json!({ + "type": "LineString", + "coordinates": right, + }); + let inserted: (i64,) = sqlx::query_as( + "INSERT INTO roads(name, geojson, min_lat, max_lat, min_lng, max_lng, + oneway, highway, snap_ignored) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id", + ) + .bind(&name) + .bind(geom.to_string()) + .bind(rn_lat).bind(rx_lat).bind(rn_lng).bind(rx_lng) + .bind(oneway) + .bind(&highway) + .bind(snap_ignored) + .fetch_one(&mut *tx) + .await + .map_err(|e| e.to_string())?; + result_ids.push(inserted.0); + } else if left_ok { + shrink(&mut tx, id, &left).await?; + result_ids.push(id); + } else { + shrink(&mut tx, id, &right).await?; + result_ids.push(id); + } + + tx.commit().await.map_err(|e| e.to_string())?; + Ok(result_ids) +} + +#[derive(Debug, Serialize)] +pub struct LassoRoadDeleteResult { + pub roads_examined: usize, + pub roads_deleted: usize, + pub roads_split: usize, + pub new_road_ids: Vec, +} + +/// Bulk-delete road sections that fall inside a lasso polygon. +/// +/// `polygon` is a sequence of `[lng, lat]` points in any order (closing point +/// optional — implicitly closed). For each road that overlaps the polygon's +/// bbox we walk segment-by-segment, intersecting each road edge with the +/// polygon boundary. Outside-pieces survive as new road rows (with boundary +/// crossings as fresh vertices); inside-pieces are dropped. This handles +/// concave lassos correctly — a segment that crosses the boundary multiple +/// times keeps every outside sub-segment. +#[tauri::command] +pub async fn delete_roads_in_lasso( + polygon: Vec<[f64; 2]>, + state: State<'_, AppState>, +) -> Result { + if polygon.len() < 3 { + return Err("lasso polygon needs at least 3 points".into()); + } + // Polygon bbox (lng, lat) — coarse pre-filter via R-tree. + let mut p_mn_lat = f64::INFINITY; + let mut p_mx_lat = f64::NEG_INFINITY; + let mut p_mn_lng = f64::INFINITY; + let mut p_mx_lng = f64::NEG_INFINITY; + for &[lng, lat] in &polygon { + if lat < p_mn_lat { p_mn_lat = lat; } + if lat > p_mx_lat { p_mx_lat = lat; } + if lng < p_mn_lng { p_mn_lng = lng; } + if lng > p_mx_lng { p_mx_lng = lng; } + } + + let candidate_rows: Vec<(i64, Option, String, i64, Option, i64)> = + sqlx::query_as( + "SELECT r.id, r.name, r.geojson, COALESCE(r.oneway, 0), r.highway, + COALESCE(r.snap_ignored, 0) + FROM roads r JOIN roads_rtree x ON x.id = r.id + WHERE x.max_lat >= ? AND x.min_lat <= ? + AND x.max_lng >= ? AND x.min_lng <= ?", + ) + .bind(p_mn_lat) + .bind(p_mx_lat) + .bind(p_mn_lng) + .bind(p_mx_lng) + .fetch_all(&state.pool) + .await + .map_err(|e| e.to_string())?; + + fn point_in_polygon(lng: f64, lat: f64, poly: &[[f64; 2]]) -> bool { + // Standard ray-casting. Boundary cases default to "inside" — fine for + // lasso UX since users expect a generous interpretation. + let mut inside = false; + let n = poly.len(); + let mut j = n - 1; + for i in 0..n { + let (xi, yi) = (poly[i][0], poly[i][1]); + let (xj, yj) = (poly[j][0], poly[j][1]); + let cond = (yi > lat) != (yj > lat) + && lng < (xj - xi) * (lat - yi) / (yj - yi + 1e-15) + xi; + if cond { inside = !inside; } + j = i; + } + inside + } + + // Segment vs. polygon-edge intersection. Returns the parameter t∈(0,1) at + // which segment a→b crosses edge p→q, or None if no proper crossing. + // Endpoints touching (t=0 / t=1) are treated as non-crossings to avoid + // double-counting at shared vertices. + fn segment_intersect_t( + a: [f64; 2], + b: [f64; 2], + p: [f64; 2], + q: [f64; 2], + ) -> Option { + let rx = b[0] - a[0]; + let ry = b[1] - a[1]; + let sx = q[0] - p[0]; + let sy = q[1] - p[1]; + let denom = rx * sy - ry * sx; + if denom.abs() < 1e-15 { + return None; + } + let dx = p[0] - a[0]; + let dy = p[1] - a[1]; + let t = (dx * sy - dy * sx) / denom; + let u = (dx * ry - dy * rx) / denom; + if t > 1e-12 && t < 1.0 - 1e-12 && u >= -1e-12 && u <= 1.0 + 1e-12 { + Some(t) + } else { + None + } + } + + fn lerp(a: [f64; 2], b: [f64; 2], t: f64) -> [f64; 2] { + [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t] + } + + let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; + let mut roads_deleted = 0usize; + let mut roads_split = 0usize; + let mut new_road_ids: Vec = Vec::new(); + let examined = candidate_rows.len(); + + for (id, name, geojson, oneway, highway, snap_ignored) in candidate_rows { + let val: serde_json::Value = match serde_json::from_str(&geojson) { + Ok(v) => v, + Err(_) => continue, + }; + let arr = match val.get("coordinates").and_then(|c| c.as_array()) { + Some(a) => a, + None => continue, + }; + 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(); + if coords.len() < 2 { continue; } + + let inside_flags: Vec = coords + .iter() + .map(|c| point_in_polygon(c[0], c[1], &polygon)) + .collect(); + + if inside_flags.iter().all(|&b| b) { + // Every vertex inside; assume edges are too. Delete outright. + sqlx::query("DELETE FROM roads WHERE id = ?") + .bind(id) + .execute(&mut *tx) + .await + .map_err(|e| e.to_string())?; + roads_deleted += 1; + continue; + } + + // Walk each edge, splitting on polygon-boundary crossings. Each + // sub-edge is wholly inside or wholly outside; outside sub-edges are + // appended to the current "kept" run and inside sub-edges close it. + let mut kept_runs: Vec> = Vec::new(); + let mut cur: Vec<[f64; 2]> = Vec::new(); + + let push_outside = |cur: &mut Vec<[f64; 2]>, p: [f64; 2]| { + // Avoid duplicating the previous endpoint (consecutive segments + // share a vertex). + if cur.last().map(|q| (q[0] - p[0]).abs() < 1e-12 && (q[1] - p[1]).abs() < 1e-12) + .unwrap_or(false) + { + return; + } + cur.push(p); + }; + let close_run = |kept_runs: &mut Vec>, cur: &mut Vec<[f64; 2]>| { + if cur.len() >= 2 { + kept_runs.push(std::mem::take(cur)); + } else { + cur.clear(); + } + }; + + for i in 0..coords.len() - 1 { + let a = coords[i]; + let b = coords[i + 1]; + // Collect all boundary crossings within this segment. + let mut ts: Vec = Vec::new(); + let n = polygon.len(); + for k in 0..n { + let p = polygon[k]; + let q = polygon[(k + 1) % n]; + if let Some(t) = segment_intersect_t(a, b, p, q) { + ts.push(t); + } + } + ts.sort_by(|x, y| x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal)); + ts.dedup_by(|x, y| (*x - *y).abs() < 1e-9); + + // Sub-segment endpoints along a→b: 0, t1, t2, …, 1 + let mut breakpoints: Vec = vec![0.0]; + breakpoints.extend(ts); + breakpoints.push(1.0); + + // Inside/outside alternates, starting from inside_flags[i]. + let mut currently_inside = inside_flags[i]; + for w in breakpoints.windows(2) { + let (t0, t1) = (w[0], w[1]); + if (t1 - t0).abs() < 1e-12 { + currently_inside = !currently_inside; + continue; + } + let p0 = lerp(a, b, t0); + let p1 = lerp(a, b, t1); + if currently_inside { + // Closes any open kept-run; the inside piece is dropped. + close_run(&mut kept_runs, &mut cur); + } else { + // Outside piece: append both endpoints (dedup against last). + push_outside(&mut cur, p0); + push_outside(&mut cur, p1); + } + currently_inside = !currently_inside; + } + } + close_run(&mut kept_runs, &mut cur); + + // The road had at least one inside vertex (we early-returned the + // wholly-inside case above), so SOMETHING changed: delete the original + // and re-insert the surviving outside runs. + sqlx::query("DELETE FROM roads WHERE id = ?") + .bind(id) + .execute(&mut *tx) + .await + .map_err(|e| e.to_string())?; + if kept_runs.is_empty() { + roads_deleted += 1; + continue; + } + for run in kept_runs { + let (mn_lat, mx_lat, mn_lng, mx_lng) = coords_bbox(&run); + let geom = serde_json::json!({ + "type": "LineString", + "coordinates": run, + }); + let inserted: (i64,) = sqlx::query_as( + "INSERT INTO roads(name, geojson, min_lat, max_lat, min_lng, max_lng, + oneway, highway, snap_ignored) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id", + ) + .bind(&name) + .bind(geom.to_string()) + .bind(mn_lat).bind(mx_lat).bind(mn_lng).bind(mx_lng) + .bind(oneway) + .bind(&highway) + .bind(snap_ignored) + .fetch_one(&mut *tx) + .await + .map_err(|e| e.to_string())?; + new_road_ids.push(inserted.0); + } + roads_split += 1; + } + tx.commit().await.map_err(|e| e.to_string())?; + + Ok(LassoRoadDeleteResult { + roads_examined: examined, + roads_deleted, + roads_split, + new_road_ids, + }) +} + /// 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. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b0c50b0..7c9a30e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,7 +10,8 @@ use commands::{ read_metadata_polyline, redo_action, reset_database, snap_assets_in_bbox, 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, + create_osm_road, create_user, delete_road_section, delete_road_vertices, + delete_roads_in_lasso, set_link, simplify_road, delete_roads_by_highway, export_osm_roads, find_duplicate_clusters, save_video_metadata, get_video_metadata, clear_video_metadata, flip_road_direction, generate_osm_for_bbox, generate_osm_near_assets, @@ -70,6 +71,8 @@ pub fn run() { update_osm_road, create_osm_road, delete_road_vertices, + delete_road_section, + delete_roads_in_lasso, simplify_road, delete_osm_road, list_asset_names, diff --git a/src/App.tsx b/src/App.tsx index 048a05b..86289e9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -400,7 +400,15 @@ function AppShell({ snap_ignored: number; }; type OsmUndoEntry = - | { kind: "restore-roads"; snapshots: OsmRoadSnap[]; label: string } + | { + kind: "restore-roads"; + snapshots: OsmRoadSnap[]; + // Rows that were *created* by the operation (e.g. the right-half row + // from a section split). Undo deletes them before restoring snapshots, + // so the original geometry is the only survivor. + createdIds?: number[]; + label: string; + } | { kind: "unhide"; ids: number[]; label: string } | { kind: "rehide"; ids: number[]; label: string }; const [osmUndoStack, setOsmUndoStack] = useState([]); @@ -423,12 +431,41 @@ function AppShell({ console.warn("osm_road_snapshot failed", e); } } + // For ops that mutate AND create new rows (delete_road_section split), the + // caller should snapshot first, run the op, then call this with the ids + // that were freshly inserted so undo can drop them on the way back. + function attachCreatedIdsToLastUndo(createdIds: number[]) { + if (createdIds.length === 0) return; + setOsmUndoStack((prev) => { + if (prev.length === 0) return prev; + const last = prev[prev.length - 1]; + if (last.kind !== "restore-roads") return prev; + const next = prev.slice(); + next[next.length - 1] = { + ...last, + createdIds: [...(last.createdIds ?? []), ...createdIds], + }; + return next; + }); + } async function performOsmUndo() { if (osmUndoStack.length === 0) return; const last = osmUndoStack[osmUndoStack.length - 1]; setOsmUndoStack((prev) => prev.slice(0, -1)); try { if (last.kind === "restore-roads") { + // Delete any rows that the operation created before restoring the + // original geometry — otherwise a split-section undo leaves a stale + // duplicate alongside the resurrected original. + if (last.createdIds && last.createdIds.length > 0) { + for (const cid of last.createdIds) { + try { + await invoke("delete_osm_road", { id: cid }); + } catch (e) { + console.warn(`undo: delete_osm_road(${cid}) failed`, e); + } + } + } const n = await invoke("restore_road_state", { snapshots: last.snapshots, }); @@ -2069,6 +2106,82 @@ function AppShell({ Math.sin(dLng / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(s)); }; + // OSM-edit-mode lasso: convert shape → polygon and bulk-delete the road + // sections that fall inside it. Skips the asset-bulk-action flow below. + if (osmEditMode) { + const m_per_lat = 111_320; + let polygon: Array<[number, number]> = []; + if (shape === "circle") { + const c = points[0]; + const edge = points[1]; + const radius_m = haversine(c, edge); + if (radius_m < 1) { + setStatus("Lasso too small."); + return; + } + const m_per_lng = 111_320 * Math.cos(toRad(c[1])); + const N = 64; + for (let i = 0; i < N; i++) { + const a = (i / N) * Math.PI * 2; + polygon.push([ + c[0] + (radius_m * Math.cos(a)) / m_per_lng, + c[1] + (radius_m * Math.sin(a)) / m_per_lat, + ]); + } + } else if (shape === "rect") { + const [a, b] = points; + const minLng = Math.min(a[0], b[0]); + const maxLng = Math.max(a[0], b[0]); + const minLat = Math.min(a[1], b[1]); + const maxLat = Math.max(a[1], b[1]); + polygon = [ + [minLng, minLat], + [maxLng, minLat], + [maxLng, maxLat], + [minLng, maxLat], + ]; + } else { + if (points.length < 3) { + setStatus("Need at least 3 polygon vertices."); + return; + } + polygon = points.map((p) => [p[0], p[1]] as [number, number]); + } + const ok = window.confirm( + `Bulk-delete OSM road sections inside this lasso?\n\n` + + `Roads fully inside are removed; roads partially inside are clipped at the lasso boundary — the inside portion is dropped, the outside portion(s) survive as separate roads. ` + + `This is NOT covered by the OSM Undo (the ring buffer doesn't recreate deleted rows).`, + ); + if (!ok) { + setLassoMode(false); + return; + } + setBusy(true); + invoke<{ + roads_examined: number; + roads_deleted: number; + roads_split: number; + new_road_ids: number[]; + }>("delete_roads_in_lasso", { polygon }) + .then(async (r) => { + setStatus( + `Lasso roads: examined ${r.roads_examined}, fully deleted ${r.roads_deleted}, split ${r.roads_split} (→ ${r.new_road_ids.length} new sub-road(s)).`, + ); + // The currently-edited / selected / marked road(s) may have been + // deleted or split — drop those UI references so the next click + // doesn't act on a stale id. + setEditingRoadId(null); + setMarkedVertices(new Set()); + setSelectedRoadIds(new Set()); + await refreshOsmRoads(); + }) + .catch((e) => setStatus(`Bulk road delete failed: ${e}`)) + .finally(() => { + setBusy(false); + setLassoMode(false); + }); + return; + } const ids: number[] = []; let label = ""; if (shape === "circle") { @@ -4667,6 +4780,56 @@ function AppShell({ > Simplify this road + {focusedVideo === null && (