linear bug and osm new features

This commit is contained in:
2026-05-08 18:28:07 +05:30
parent 6eb854444d
commit 2660ef12b9
4 changed files with 781 additions and 23 deletions

View File

@@ -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<NumOrStr>,
#[serde(default)]
coord_lng: Option<NumOrStr>,
start_coord_lat: NumOrStr,
start_coord_lng: NumOrStr,
end_coord_lat: NumOrStr,
@@ -81,6 +87,19 @@ struct RangeAssetInput {
side: Option<String>,
}
fn opt_num_or_str_to_f64(v: &Option<NumOrStr>) -> Option<f64> {
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::<f64>().ok().filter(|x| x.is_finite())
}
}
}
use std::sync::{OnceLock, RwLock};
static CURRENT_USER: OnceLock<RwLock<String>> = 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::<f64>().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<usize>,
state: State<'_, AppState>,
) -> Result<Vec<i64>, String> {
if indices.len() < 2 {
return Err("need at least 2 marked vertices to define a section".into());
}
let row: (Option<String>, String, i64, Option<String>, 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<i64> = 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<i64>,
}
/// 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<LassoRoadDeleteResult, String> {
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>, String, i64, Option<String>, 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<f64> {
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<i64> = 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<bool> = 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<[f64; 2]>> = 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<Vec<[f64; 2]>>, 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<f64> = 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<f64> = 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.510 m.