Files
KML-Verification/src-tauri/src/commands.rs
2026-05-19 18:31:44 +05:30

7106 lines
254 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use crate::scope;
use geo::algorithm::Distance;
use geo::{ClosestPoint, Closest, Coord, Geodesic, LineString, Point};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use std::path::Path;
use tauri::State;
pub struct AppState {
pub pool: SqlitePool,
}
#[derive(Debug, Deserialize)]
struct FixedAssetInput {
row_id: String,
asset_name: String,
video_name: String,
coord: CoordValue,
image_path: Option<String>,
#[serde(default)]
deleted: bool,
#[serde(default)]
side: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum CoordValue {
Floats([f64; 2]),
Strings([String; 2]),
}
impl CoordValue {
fn to_lat_lng(&self) -> Result<(f64, f64), String> {
match self {
CoordValue::Floats([lat, lng]) => Ok((*lat, *lng)),
CoordValue::Strings([lat, lng]) => {
let lat: f64 = lat.parse().map_err(|_| "invalid lat string".to_string())?;
let lng: f64 = lng.parse().map_err(|_| "invalid lng string".to_string())?;
Ok((lat, lng))
}
}
}
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum NumOrStr {
Num(f64),
Str(String),
}
impl NumOrStr {
fn to_f64(&self) -> Result<f64, String> {
match self {
NumOrStr::Num(v) => Ok(*v),
NumOrStr::Str(s) => s
.parse::<f64>()
.map_err(|_| format!("invalid float: {}", s)),
}
}
}
#[derive(Debug, Deserialize)]
struct RangeAssetInput {
row_id: String,
asset_name: String,
video_name: String,
// 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,
end_coord_lng: NumOrStr,
image_path1: Option<String>,
image_path2: Option<String>,
image_path3: Option<String>,
#[serde(default)]
deleted: bool,
#[serde(default)]
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 {
CURRENT_USER
.get_or_init(|| RwLock::new("default".to_string()))
.read()
.unwrap()
.clone()
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct User {
pub id: i64,
pub username: String,
pub display_name: String,
pub created_at: String,
}
#[tauri::command]
pub async fn list_users(state: State<'_, AppState>) -> Result<Vec<User>, String> {
sqlx::query_as::<_, User>(
"SELECT id, username, display_name, created_at FROM users ORDER BY username",
)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn create_user(
username: String,
display_name: String,
state: State<'_, AppState>,
) -> Result<i64, String> {
let row: (i64,) = sqlx::query_as(
"INSERT INTO users (username, display_name) VALUES (?, ?) RETURNING id",
)
.bind(username.trim())
.bind(display_name.trim())
.fetch_one(&state.pool)
.await
.map_err(|e| e.to_string())?;
Ok(row.0)
}
#[tauri::command]
pub async fn delete_user(id: i64, state: State<'_, AppState>) -> Result<(), String> {
sqlx::query("DELETE FROM users WHERE id = ?")
.bind(id)
.execute(&state.pool)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub fn set_current_user(username: String) -> Result<(), String> {
let lock = CURRENT_USER.get_or_init(|| RwLock::new("default".to_string()));
*lock.write().unwrap() = username;
Ok(())
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct AuditRow {
pub id: i64,
pub action_type: String,
pub scope: String,
pub asset_ids: String,
pub user_id: String,
pub before: Option<String>,
pub after: Option<String>,
pub ts: String,
pub undone: i64,
}
#[derive(Debug, Deserialize, Serialize)]
struct MoveAfter {
vertex: String,
lat: f64,
lng: f64,
}
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct Asset {
pub id: i64,
pub row_id: String,
pub asset_type: String,
pub asset_name: String,
pub video_name: String,
pub lat: Option<f64>,
pub lng: Option<f64>,
pub start_lat: Option<f64>,
pub start_lng: Option<f64>,
pub end_lat: Option<f64>,
pub end_lng: Option<f64>,
pub image_path: Option<String>,
pub image_path1: Option<String>,
pub image_path2: Option<String>,
pub image_path3: Option<String>,
pub deleted: i64,
pub modified: i64,
pub merged_into: Option<i64>,
pub in_scope: Option<i64>,
pub side: Option<String>,
pub link_pair_id: Option<i64>,
pub link_locked: Option<i64>,
}
fn normalize_side(s: &str) -> Option<String> {
match s.trim().to_uppercase().as_str() {
"LHS" | "LEFT" | "L" => Some("Left".to_string()),
"RHS" | "RIGHT" | "R" => Some("Right".to_string()),
_ => None,
}
}
// Per-row insert helpers extracted so the auto-detect importer
// (`import_assets_auto`) can share the exact INSERT/ON CONFLICT logic with
// the type-specific importers — keeps re-import preserve-edits semantics
// identical no matter which entry path the user takes.
async fn insert_fixed_row(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
input: &FixedAssetInput,
) -> Result<(), String> {
let (lat, lng) = input.coord.to_lat_lng()?;
let side = input.side.as_deref().and_then(normalize_side);
sqlx::query(
r#"
INSERT INTO assets (
row_id, asset_type, asset_name, video_name,
lat, lng, min_lat, max_lat, min_lng, max_lng,
image_path, deleted, side
) VALUES (?, 'fixed', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(row_id) DO UPDATE SET
asset_type = excluded.asset_type,
asset_name = excluded.asset_name,
video_name = excluded.video_name,
lat = CASE WHEN assets.modified = 0 THEN excluded.lat ELSE assets.lat END,
lng = CASE WHEN assets.modified = 0 THEN excluded.lng ELSE assets.lng END,
min_lat = CASE WHEN assets.modified = 0 THEN excluded.min_lat ELSE assets.min_lat END,
max_lat = CASE WHEN assets.modified = 0 THEN excluded.max_lat ELSE assets.max_lat END,
min_lng = CASE WHEN assets.modified = 0 THEN excluded.min_lng ELSE assets.min_lng END,
max_lng = CASE WHEN assets.modified = 0 THEN excluded.max_lng ELSE assets.max_lng END,
image_path = excluded.image_path,
-- Clear range-only fields when a row migrates range → fixed.
-- Fixed assets carry only `image_path` and a single (lat, lng);
-- leaving the old triple paths or start/end coords in place
-- would render stale S/M/E pins and a stale popup image.
image_path1 = NULL,
image_path2 = NULL,
image_path3 = NULL,
start_lat = NULL,
start_lng = NULL,
end_lat = NULL,
end_lng = NULL,
deleted = CASE WHEN assets.deleted = 1 THEN 1 ELSE excluded.deleted END,
side = CASE WHEN assets.modified = 0 THEN excluded.side ELSE assets.side END
"#,
)
.bind(&input.row_id)
.bind(&input.asset_name)
.bind(&input.video_name)
.bind(lat)
.bind(lng)
.bind(lat)
.bind(lat)
.bind(lng)
.bind(lng)
.bind(&input.image_path)
.bind(input.deleted as i64)
.bind(&side)
.execute(&mut **tx)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
async fn insert_range_row(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
input: &RangeAssetInput,
) -> Result<(), String> {
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);
let max_lng = mid_lng.max(s_lng).max(e_lng);
let side = input.side.as_deref().and_then(normalize_side);
sqlx::query(
r#"
INSERT INTO assets (
row_id, asset_type, asset_name, video_name,
lat, lng, start_lat, start_lng, end_lat, end_lng,
min_lat, max_lat, min_lng, max_lng,
image_path1, image_path2, image_path3, deleted, side
) VALUES (?, 'range', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(row_id) DO UPDATE SET
asset_type = excluded.asset_type,
asset_name = excluded.asset_name,
video_name = excluded.video_name,
lat = CASE WHEN assets.modified = 0 THEN excluded.lat ELSE assets.lat END,
lng = CASE WHEN assets.modified = 0 THEN excluded.lng ELSE assets.lng END,
start_lat = CASE WHEN assets.modified = 0 THEN excluded.start_lat ELSE assets.start_lat END,
start_lng = CASE WHEN assets.modified = 0 THEN excluded.start_lng ELSE assets.start_lng END,
end_lat = CASE WHEN assets.modified = 0 THEN excluded.end_lat ELSE assets.end_lat END,
end_lng = CASE WHEN assets.modified = 0 THEN excluded.end_lng ELSE assets.end_lng END,
min_lat = CASE WHEN assets.modified = 0 THEN excluded.min_lat ELSE assets.min_lat END,
max_lat = CASE WHEN assets.modified = 0 THEN excluded.max_lat ELSE assets.max_lat END,
min_lng = CASE WHEN assets.modified = 0 THEN excluded.min_lng ELSE assets.min_lng END,
max_lng = CASE WHEN assets.modified = 0 THEN excluded.max_lng ELSE assets.max_lng END,
image_path1 = excluded.image_path1,
image_path2 = excluded.image_path2,
image_path3 = excluded.image_path3,
-- Clear the singular `image_path` when a row migrates fixed →
-- range. Range assets use image_path1/2/3 only; leaving the old
-- singular path would surface as a stale "main" image in any
-- consumer that prefers it over the triple.
image_path = NULL,
deleted = CASE WHEN assets.deleted = 1 THEN 1 ELSE excluded.deleted END,
side = CASE WHEN assets.modified = 0 THEN excluded.side ELSE assets.side END
"#,
)
.bind(&input.row_id)
.bind(&input.asset_name)
.bind(&input.video_name)
.bind(mid_lat)
.bind(mid_lng)
.bind(s_lat)
.bind(s_lng)
.bind(e_lat)
.bind(e_lng)
.bind(min_lat)
.bind(max_lat)
.bind(min_lng)
.bind(max_lng)
.bind(&input.image_path1)
.bind(&input.image_path2)
.bind(&input.image_path3)
.bind(input.deleted as i64)
.bind(&side)
.execute(&mut **tx)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
/// Parse a single bbox prefix from a row and write it into asset_bboxes for
/// the given slot. Accepts numeric or string values and silently skips slots
/// where any of the four coordinate fields are missing / unparseable. Class
/// is asset-level (assets.bbox_class) and handled separately by the caller.
async fn maybe_insert_bbox_from_row(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
asset_id: i64,
row: &serde_json::Value,
prefix: &str,
slot: &str,
) -> Result<(), String> {
fn parse_f(v: Option<&serde_json::Value>) -> Option<f64> {
match v? {
serde_json::Value::Number(n) => n.as_f64().filter(|x| x.is_finite()),
serde_json::Value::String(s) => {
let t = s.trim();
if t.is_empty() {
None
} else {
t.parse::<f64>().ok().filter(|x| x.is_finite())
}
}
_ => None,
}
}
let x1 = parse_f(row.get(&format!("{prefix}bbox_x1")));
let y1 = parse_f(row.get(&format!("{prefix}bbox_y1")));
let x2 = parse_f(row.get(&format!("{prefix}bbox_x2")));
let y2 = parse_f(row.get(&format!("{prefix}bbox_y2")));
let (Some(x1), Some(y1), Some(x2), Some(y2)) = (x1, y1, x2, y2) else {
return Ok(());
};
let (nx1, nx2) = if x1 <= x2 { (x1, x2) } else { (x2, x1) };
let (ny1, ny2) = if y1 <= y2 { (y1, y2) } else { (y2, y1) };
if (nx2 - nx1).abs() < 1.0 || (ny2 - ny1).abs() < 1.0 {
return Ok(());
}
sqlx::query(
"INSERT INTO asset_bboxes(asset_id, slot, x1, y1, x2, y2)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(asset_id, slot) DO UPDATE SET
x1 = excluded.x1, y1 = excluded.y1,
x2 = excluded.x2, y2 = excluded.y2",
)
.bind(asset_id)
.bind(slot)
.bind(nx1)
.bind(ny1)
.bind(nx2)
.bind(ny2)
.execute(&mut **tx)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
/// Resolve the asset id for a row_id we just inserted, then attempt to ingest
/// any bbox fields. For fixed rows we look for the flat `bbox_*` keys; for
/// range rows we additionally check `start_bbox_*` / `mid_bbox_*` /
/// `end_bbox_*` so each image-slot can carry its own bbox. The (asset-level)
/// `bbox_class` field, if present anywhere in the row, is stored on assets.
async fn ingest_bboxes_for_row(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
row_id: &str,
raw: &serde_json::Value,
is_range: bool,
) -> Result<(), String> {
let asset_id: Option<i64> =
sqlx::query_scalar("SELECT id FROM assets WHERE row_id = ?")
.bind(row_id)
.fetch_optional(&mut **tx)
.await
.map_err(|e| e.to_string())?;
let Some(asset_id) = asset_id else {
return Ok(());
};
if is_range {
// Flat bbox_* maps to mid by convention; explicit per-slot prefixes
// override that when present.
maybe_insert_bbox_from_row(tx, asset_id, raw, "", "mid").await?;
maybe_insert_bbox_from_row(tx, asset_id, raw, "start_", "start").await?;
maybe_insert_bbox_from_row(tx, asset_id, raw, "mid_", "mid").await?;
maybe_insert_bbox_from_row(tx, asset_id, raw, "end_", "end").await?;
} else {
maybe_insert_bbox_from_row(tx, asset_id, raw, "", "fixed").await?;
}
// Class lives on assets.asset_name, populated by the regular import path.
// No bbox_class write here — `set_asset_bbox` API stays purely geometric
// and class renames flow through the existing rename_assets command.
let _ = asset_id;
Ok(())
}
async fn stamp_orig_positions(pool: &SqlitePool) -> Result<(), String> {
sqlx::query(
"UPDATE assets
SET orig_lat = COALESCE(orig_lat, lat),
orig_lng = COALESCE(orig_lng, lng),
orig_start_lat = COALESCE(orig_start_lat, start_lat),
orig_start_lng = COALESCE(orig_start_lng, start_lng),
orig_end_lat = COALESCE(orig_end_lat, end_lat),
orig_end_lng = COALESCE(orig_end_lng, end_lng)",
)
.execute(pool)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn import_fixed_assets(
path: String,
state: State<'_, AppState>,
) -> Result<usize, String> {
let bytes = tokio::fs::read(&path).await.map_err(|e| e.to_string())?;
// Deserialize as raw JSON first so we can still see bbox_* keys that
// aren't on FixedAssetInput. Each row is then converted to the typed
// shape for INSERT and re-used for bbox ingest.
let raws: Vec<serde_json::Value> =
serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?;
let mut count = 0usize;
for raw in raws {
let input: FixedAssetInput =
serde_json::from_value(raw.clone()).map_err(|e| e.to_string())?;
let row_id = input.row_id.clone();
insert_fixed_row(&mut tx, &input).await?;
ingest_bboxes_for_row(&mut tx, &row_id, &raw, false).await?;
count += 1;
}
tx.commit().await.map_err(|e| e.to_string())?;
stamp_orig_positions(&state.pool).await?;
apply_scope_after_change(&state.pool).await?;
Ok(count)
}
#[tauri::command]
pub async fn import_range_assets(
path: String,
state: State<'_, AppState>,
) -> Result<usize, String> {
let bytes = tokio::fs::read(&path).await.map_err(|e| e.to_string())?;
let raws: Vec<serde_json::Value> =
serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?;
let mut count = 0usize;
for raw in raws {
let input: RangeAssetInput =
serde_json::from_value(raw.clone()).map_err(|e| e.to_string())?;
let row_id = input.row_id.clone();
insert_range_row(&mut tx, &input).await?;
ingest_bboxes_for_row(&mut tx, &row_id, &raw, true).await?;
count += 1;
}
tx.commit().await.map_err(|e| e.to_string())?;
stamp_orig_positions(&state.pool).await?;
apply_scope_after_change(&state.pool).await?;
Ok(count)
}
#[derive(Debug, Serialize)]
pub struct AutoImportResult {
pub fixed: usize,
pub range: usize,
}
/// Auto-detect importer: takes a JSON file containing either
/// 1. A flat `[ { row… }, { row… }, … ]` array where each row may be a
/// fixed asset (has `coord`) or a range asset (has `start_coord_lat`
/// etc.); both shapes can be mixed in one file.
/// 2. The round-trip export shape `{ "fixed": [...], "range": [...] }`.
/// Each row is dispatched per-shape into the same INSERT/ON CONFLICT logic
/// the type-specific importers use, so re-import preserve-edits semantics
/// behave identically.
#[tauri::command]
pub async fn import_assets_auto(
path: String,
state: State<'_, AppState>,
) -> Result<AutoImportResult, String> {
let bytes = tokio::fs::read(&path).await.map_err(|e| e.to_string())?;
let raw: serde_json::Value =
serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
// Two shapes: a top-level array (mixed rows) or the export object.
let rows: Vec<serde_json::Value> = match raw {
serde_json::Value::Array(a) => a,
serde_json::Value::Object(map) => {
let mut out: Vec<serde_json::Value> = Vec::new();
if let Some(serde_json::Value::Array(a)) = map.get("fixed").cloned() {
out.extend(a);
}
if let Some(serde_json::Value::Array(a)) = map.get("range").cloned() {
out.extend(a);
}
if out.is_empty() {
return Err(
"expected an array of rows or an object with `fixed` / `range` arrays"
.into(),
);
}
out
}
_ => return Err("expected JSON array or object".into()),
};
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 {
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.clone()).map_err(|e| e.to_string())?;
let row_id = input.row_id.clone();
insert_range_row(&mut tx, &input).await?;
ingest_bboxes_for_row(&mut tx, &row_id, &row, true).await?;
range_count += 1;
} else {
let input: FixedAssetInput =
serde_json::from_value(row.clone()).map_err(|e| e.to_string())?;
let row_id = input.row_id.clone();
insert_fixed_row(&mut tx, &input).await?;
ingest_bboxes_for_row(&mut tx, &row_id, &row, false).await?;
fixed_count += 1;
}
}
tx.commit().await.map_err(|e| e.to_string())?;
stamp_orig_positions(&state.pool).await?;
apply_scope_after_change(&state.pool).await?;
Ok(AutoImportResult {
fixed: fixed_count,
range: range_count,
})
}
async fn apply_scope_after_change(pool: &SqlitePool) -> Result<(), String> {
let geoms = scope::load_geoms(pool).await.map_err(|e| e.to_string())?;
if geoms.is_empty() {
return Ok(());
}
scope::apply_scope_to_assets(pool, &geoms)
.await
.map(|_| ())
.map_err(|e| e.to_string())
}
#[derive(Debug, Serialize)]
pub struct ScopePolygonOut {
pub id: i64,
pub name: Option<String>,
pub geojson: String,
pub geom_type: String,
}
#[tauri::command]
pub async fn import_kml_scope(
path: String,
state: State<'_, AppState>,
) -> Result<usize, String> {
let parsed = scope::parse_kml(Path::new(&path))
.await
.map_err(|e| e.to_string())?;
scope::replace_scope(&state.pool, &parsed)
.await
.map_err(|e| e.to_string())?;
let geoms: Vec<_> = parsed.iter().map(|p| p.geom.clone()).collect();
scope::apply_scope_to_assets(&state.pool, &geoms)
.await
.map_err(|e| e.to_string())?;
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>,
) -> Result<Vec<ScopePolygonOut>, String> {
let rows: Vec<(i64, Option<String>, String, String)> =
sqlx::query_as("SELECT id, name, geojson, geom_type FROM scope_polygons")
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
Ok(rows
.into_iter()
.map(|(id, name, geojson, geom_type)| ScopePolygonOut {
id,
name,
geojson,
geom_type,
})
.collect())
}
#[tauri::command]
pub async fn update_asset_position(
id: i64,
vertex: String,
lat: f64,
lng: f64,
state: State<'_, AppState>,
) -> Result<(), String> {
let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?;
let row = sqlx::query_as::<_, Asset>(
"SELECT id, row_id, asset_type, asset_name, video_name,
lat, lng, start_lat, start_lng, end_lat, end_lng,
image_path, image_path1, image_path2, image_path3,
deleted, modified, merged_into, in_scope, side, link_pair_id, link_locked
FROM assets WHERE id = ?",
)
.bind(id)
.fetch_one(&mut *tx)
.await
.map_err(|e| e.to_string())?;
let before_json = serde_json::to_string(&row).map_err(|e| e.to_string())?;
let (new_lat, new_lng, new_slat, new_slng, new_elat, new_elng) = match vertex.as_str() {
"fixed" | "mid" => (
Some(lat),
Some(lng),
row.start_lat,
row.start_lng,
row.end_lat,
row.end_lng,
),
"start" => (
row.lat,
row.lng,
Some(lat),
Some(lng),
row.end_lat,
row.end_lng,
),
"end" => (
row.lat,
row.lng,
row.start_lat,
row.start_lng,
Some(lat),
Some(lng),
),
other => return Err(format!("invalid vertex: {}", other)),
};
let lats: Vec<f64> = [new_lat, new_slat, new_elat].into_iter().flatten().collect();
let lngs: Vec<f64> = [new_lng, new_slng, new_elng].into_iter().flatten().collect();
let min_lat = lats.iter().cloned().fold(f64::INFINITY, f64::min);
let max_lat = lats.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let min_lng = lngs.iter().cloned().fold(f64::INFINITY, f64::min);
let max_lng = lngs.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
sqlx::query(
"UPDATE assets SET
lat = ?, lng = ?,
start_lat = ?, start_lng = ?,
end_lat = ?, end_lng = ?,
min_lat = ?, max_lat = ?,
min_lng = ?, max_lng = ?,
modified = 1,
modified_by = ?,
modified_at = datetime('now')
WHERE id = ?",
)
.bind(new_lat)
.bind(new_lng)
.bind(new_slat)
.bind(new_slng)
.bind(new_elat)
.bind(new_elng)
.bind(min_lat)
.bind(max_lat)
.bind(min_lng)
.bind(max_lng)
.bind(user_id())
.bind(id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
let after_json = serde_json::to_string(&MoveAfter {
vertex: vertex.clone(),
lat,
lng,
})
.map_err(|e| e.to_string())?;
sqlx::query(
"INSERT INTO action_history (action_type, scope, asset_ids, user_id, before, after, ts)
VALUES ('move', 'single', ?, ?, ?, ?, datetime('now'))",
)
.bind(format!("[{}]", id))
.bind(user_id())
.bind(&before_json)
.bind(&after_json)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
tx.commit().await.map_err(|e| e.to_string())?;
apply_scope_after_change(&state.pool).await?;
Ok(())
}
#[derive(Debug, serde::Deserialize)]
pub struct TranslateCorrection {
pub id: i64,
pub dlat: f64,
pub dlng: f64,
}
#[tauri::command]
pub async fn bulk_translate(
corrections: Vec<TranslateCorrection>,
state: State<'_, AppState>,
) -> Result<usize, String> {
if corrections.is_empty() {
return Ok(0);
}
let ids: Vec<i64> = corrections.iter().map(|c| c.id).collect();
// SQLite caps bind params at 999; chunk the prefetch SELECT.
let mut rows: Vec<Asset> = Vec::with_capacity(ids.len());
for chunk in ids.chunks(500) {
let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(",");
let sql = format!(
"SELECT id, row_id, asset_type, asset_name, video_name,
lat, lng, start_lat, start_lng, end_lat, end_lng,
image_path, image_path1, image_path2, image_path3,
deleted, modified, merged_into, in_scope, side, link_pair_id, link_locked
FROM assets WHERE id IN ({})",
placeholders
);
let mut q = sqlx::query_as::<_, Asset>(&sql);
for id in chunk {
q = q.bind(id);
}
let part: Vec<Asset> = q
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
rows.extend(part);
}
use std::collections::HashMap;
let by_id: HashMap<i64, &Asset> = rows.iter().map(|a| (a.id, a)).collect();
let mut before_map = serde_json::Map::new();
let mut after_map = serde_json::Map::new();
let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?;
let mut changed = 0usize;
for c in &corrections {
let Some(row) = by_id.get(&c.id) else { continue };
before_map.insert(
c.id.to_string(),
serde_json::json!({
"lat": row.lat,
"lng": row.lng,
"start_lat": row.start_lat,
"start_lng": row.start_lng,
"end_lat": row.end_lat,
"end_lng": row.end_lng,
"modified": row.modified,
}),
);
let new_lat = row.lat.map(|v| v + c.dlat);
let new_lng = row.lng.map(|v| v + c.dlng);
let new_slat = row.start_lat.map(|v| v + c.dlat);
let new_slng = row.start_lng.map(|v| v + c.dlng);
let new_elat = row.end_lat.map(|v| v + c.dlat);
let new_elng = row.end_lng.map(|v| v + c.dlng);
let lats: Vec<f64> = [new_lat, new_slat, new_elat]
.into_iter()
.flatten()
.collect();
let lngs: Vec<f64> = [new_lng, new_slng, new_elng]
.into_iter()
.flatten()
.collect();
if lats.is_empty() || lngs.is_empty() {
continue;
}
let mn_lat = lats.iter().cloned().fold(f64::INFINITY, f64::min);
let mx_lat = lats.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let mn_lng = lngs.iter().cloned().fold(f64::INFINITY, f64::min);
let mx_lng = lngs.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
sqlx::query(
"UPDATE assets SET
lat = ?, lng = ?, start_lat = ?, start_lng = ?,
end_lat = ?, end_lng = ?,
min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?,
modified = 1, modified_by = ?, modified_at = datetime('now')
WHERE id = ?",
)
.bind(new_lat)
.bind(new_lng)
.bind(new_slat)
.bind(new_slng)
.bind(new_elat)
.bind(new_elng)
.bind(mn_lat)
.bind(mx_lat)
.bind(mn_lng)
.bind(mx_lng)
.bind(user_id())
.bind(c.id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
after_map.insert(
c.id.to_string(),
serde_json::json!({
"lat": new_lat,
"lng": new_lng,
"start_lat": new_slat,
"start_lng": new_slng,
"end_lat": new_elat,
"end_lng": new_elng,
"modified": 1,
}),
);
changed += 1;
}
if changed == 0 {
return Ok(0);
}
let ids_json = serde_json::to_string(&ids).map_err(|e| e.to_string())?;
sqlx::query(
"INSERT INTO action_history (action_type, scope, asset_ids, user_id, before, after, ts)
VALUES ('bulk_translate', 'bulk', ?, ?, ?, ?, datetime('now'))",
)
.bind(&ids_json)
.bind(user_id())
.bind(serde_json::to_string(&before_map).map_err(|e| e.to_string())?)
.bind(serde_json::to_string(&after_map).map_err(|e| e.to_string())?)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
tx.commit().await.map_err(|e| e.to_string())?;
apply_scope_after_change(&state.pool).await?;
Ok(changed)
}
#[tauri::command]
pub async fn reset_assets_to_original(
ids: Vec<i64>,
state: State<'_, AppState>,
) -> Result<usize, String> {
if ids.is_empty() {
return Ok(0);
}
// SQLite caps bind params at 999; chunk both SELECTs.
let mut rows: Vec<Asset> = Vec::with_capacity(ids.len());
for chunk in ids.chunks(500) {
let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(",");
let select_sql = format!(
"SELECT id, row_id, asset_type, asset_name, video_name,
lat, lng, start_lat, start_lng, end_lat, end_lng,
image_path, image_path1, image_path2, image_path3,
deleted, modified, merged_into, in_scope, side, link_pair_id, link_locked
FROM assets WHERE id IN ({})",
placeholders
);
let mut q = sqlx::query_as::<_, Asset>(&select_sql);
for id in chunk {
q = q.bind(id);
}
let part: Vec<Asset> = q
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
rows.extend(part);
}
// Snapshot before & fetch originals (chunked the same way).
type OrigRow = (
i64,
Option<f64>,
Option<f64>,
Option<f64>,
Option<f64>,
Option<f64>,
Option<f64>,
String,
);
let mut origs: Vec<OrigRow> = Vec::with_capacity(ids.len());
for chunk in ids.chunks(500) {
let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(",");
let orig_sql = format!(
"SELECT id, orig_lat, orig_lng, orig_start_lat, orig_start_lng,
orig_end_lat, orig_end_lng, asset_type
FROM assets WHERE id IN ({})",
placeholders
);
let mut oq = sqlx::query_as::<_, OrigRow>(&orig_sql);
for id in chunk {
oq = oq.bind(id);
}
let part = oq
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
origs.extend(part);
}
use std::collections::HashMap;
let orig_map: HashMap<i64, (Option<f64>, Option<f64>, Option<f64>, Option<f64>, Option<f64>, Option<f64>, String)> =
origs
.into_iter()
.map(|(id, a, b, c, d, e, f, t)| (id, (a, b, c, d, e, f, t)))
.collect();
let mut before_map = serde_json::Map::new();
let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?;
let mut changed = 0usize;
for row in &rows {
let Some(o) = orig_map.get(&row.id) else { continue };
// No original recorded (asset created before this column existed): skip.
let has_orig = o.0.is_some()
|| o.2.is_some()
|| o.4.is_some();
if !has_orig {
continue;
}
let (new_lat, new_lng, new_slat, new_slng, new_elat, new_elng) = if o.6 == "range" {
(o.0, o.1, o.2, o.3, o.4, o.5)
} else {
(o.0, o.1, None, None, None, None)
};
before_map.insert(
row.id.to_string(),
serde_json::json!({
"lat": row.lat,
"lng": row.lng,
"start_lat": row.start_lat,
"start_lng": row.start_lng,
"end_lat": row.end_lat,
"end_lng": row.end_lng,
"modified": row.modified,
}),
);
let lats: Vec<f64> = [new_lat, new_slat, new_elat]
.into_iter()
.flatten()
.collect();
let lngs: Vec<f64> = [new_lng, new_slng, new_elng]
.into_iter()
.flatten()
.collect();
if lats.is_empty() || lngs.is_empty() {
continue;
}
let mn_lat = lats.iter().cloned().fold(f64::INFINITY, f64::min);
let mx_lat = lats.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let mn_lng = lngs.iter().cloned().fold(f64::INFINITY, f64::min);
let mx_lng = lngs.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
sqlx::query(
"UPDATE assets SET
lat = ?, lng = ?,
start_lat = ?, start_lng = ?,
end_lat = ?, end_lng = ?,
min_lat = ?, max_lat = ?,
min_lng = ?, max_lng = ?,
modified = 0,
modified_by = ?,
modified_at = datetime('now')
WHERE id = ?",
)
.bind(new_lat)
.bind(new_lng)
.bind(new_slat)
.bind(new_slng)
.bind(new_elat)
.bind(new_elng)
.bind(mn_lat)
.bind(mx_lat)
.bind(mn_lng)
.bind(mx_lng)
.bind(user_id())
.bind(row.id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
changed += 1;
}
if changed == 0 {
return Ok(0);
}
let ids_json = serde_json::to_string(&ids).map_err(|e| e.to_string())?;
sqlx::query(
"INSERT INTO action_history (action_type, scope, asset_ids, user_id, before, ts)
VALUES ('reset_position', 'bulk', ?, ?, ?, datetime('now'))",
)
.bind(&ids_json)
.bind(user_id())
.bind(serde_json::to_string(&before_map).map_err(|e| e.to_string())?)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
tx.commit().await.map_err(|e| e.to_string())?;
apply_scope_after_change(&state.pool).await?;
Ok(changed)
}
#[tauri::command]
pub async fn delete_asset(id: i64, state: State<'_, AppState>) -> Result<(), String> {
let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?;
let res = sqlx::query("UPDATE assets SET deleted = 1, modified = 1, modified_by = ?, modified_at = datetime('now') WHERE id = ? AND deleted = 0")
.bind(user_id())
.bind(id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
if res.rows_affected() == 0 {
return Err("asset not found or already deleted".to_string());
}
sqlx::query(
"INSERT INTO action_history (action_type, scope, asset_ids, user_id, ts)
VALUES ('delete', 'single', ?, ?, datetime('now'))",
)
.bind(format!("[{}]", id))
.bind(user_id())
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
tx.commit().await.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn delete_assets_by_video(
video_name: String,
state: State<'_, AppState>,
) -> Result<usize, String> {
let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?;
let ids: Vec<(i64,)> = sqlx::query_as(
"SELECT id FROM assets WHERE video_name = ? AND deleted = 0",
)
.bind(&video_name)
.fetch_all(&mut *tx)
.await
.map_err(|e| e.to_string())?;
if ids.is_empty() {
return Ok(0);
}
let id_list: Vec<i64> = ids.iter().map(|(i,)| *i).collect();
sqlx::query("UPDATE assets SET deleted = 1, modified = 1, modified_by = ?, modified_at = datetime('now') WHERE video_name = ? AND deleted = 0")
.bind(user_id())
.bind(&video_name)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
let ids_json = serde_json::to_string(&id_list).map_err(|e| e.to_string())?;
sqlx::query(
"INSERT INTO action_history (action_type, scope, asset_ids, user_id, before, ts)
VALUES ('delete_by_video', 'video', ?, ?, ?, datetime('now'))",
)
.bind(&ids_json)
.bind(user_id())
.bind(&video_name)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
tx.commit().await.map_err(|e| e.to_string())?;
Ok(id_list.len())
}
#[tauri::command]
pub async fn merge_polylines(
primary_id: i64,
secondary_id: i64,
state: State<'_, AppState>,
) -> Result<(), String> {
if primary_id == secondary_id {
return Err("primary and secondary must differ".to_string());
}
let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?;
let primary: Asset = sqlx::query_as(
"SELECT id, row_id, asset_type, asset_name, video_name,
lat, lng, start_lat, start_lng, end_lat, end_lng,
image_path, image_path1, image_path2, image_path3,
deleted, modified, merged_into, in_scope, side, link_pair_id, link_locked
FROM assets WHERE id = ?",
)
.bind(primary_id)
.fetch_one(&mut *tx)
.await
.map_err(|e| e.to_string())?;
let secondary: Asset = sqlx::query_as(
"SELECT id, row_id, asset_type, asset_name, video_name,
lat, lng, start_lat, start_lng, end_lat, end_lng,
image_path, image_path1, image_path2, image_path3,
deleted, modified, merged_into, in_scope, side, link_pair_id, link_locked
FROM assets WHERE id = ?",
)
.bind(secondary_id)
.fetch_one(&mut *tx)
.await
.map_err(|e| e.to_string())?;
if primary.asset_type != "range" || secondary.asset_type != "range" {
return Err("both assets must be range".to_string());
}
if secondary.merged_into.is_some() {
return Err("secondary is already merged".to_string());
}
if secondary.deleted == 1 {
return Err("secondary is deleted".to_string());
}
// Snapshot primary BEFORE we modify its geometry — needed to undo a merge.
let primary_before = serde_json::to_string(&primary).map_err(|e| e.to_string())?;
// Combine geometry: pick the closest pair of endpoints (one from each polyline)
// as the "join" — the OTHER two endpoints become the new outer start/end.
let p_start = (
primary.start_lat.ok_or("primary has no start_lat")?,
primary.start_lng.ok_or("primary has no start_lng")?,
);
let p_end = (
primary.end_lat.ok_or("primary has no end_lat")?,
primary.end_lng.ok_or("primary has no end_lng")?,
);
let s_start = (
secondary.start_lat.ok_or("secondary has no start_lat")?,
secondary.start_lng.ok_or("secondary has no start_lng")?,
);
let s_end = (
secondary.end_lat.ok_or("secondary has no end_lat")?,
secondary.end_lng.ok_or("secondary has no end_lng")?,
);
let dist = |a: (f64, f64), b: (f64, f64)| {
Geodesic.distance(Point::new(a.1, a.0), Point::new(b.1, b.0))
};
let d_ss = dist(p_start, s_start);
let d_se = dist(p_start, s_end);
let d_es = dist(p_end, s_start);
let d_ee = dist(p_end, s_end);
let min_d = d_ss.min(d_se).min(d_es).min(d_ee);
let (new_start, new_end) = if (min_d - d_ss).abs() < 1e-9 {
(p_end, s_end)
} else if (min_d - d_se).abs() < 1e-9 {
(p_end, s_start)
} else if (min_d - d_es).abs() < 1e-9 {
(p_start, s_end)
} else {
(p_start, s_start)
};
let new_mid = (
(new_start.0 + new_end.0) / 2.0,
(new_start.1 + new_end.1) / 2.0,
);
let mn_lat = new_start.0.min(new_mid.0).min(new_end.0);
let mx_lat = new_start.0.max(new_mid.0).max(new_end.0);
let mn_lng = new_start.1.min(new_mid.1).min(new_end.1);
let mx_lng = new_start.1.max(new_mid.1).max(new_end.1);
// 1) extend primary to combined geometry
sqlx::query(
"UPDATE assets SET
lat = ?, lng = ?,
start_lat = ?, start_lng = ?,
end_lat = ?, end_lng = ?,
min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?,
modified = 1, modified_by = ?, modified_at = datetime('now')
WHERE id = ?",
)
.bind(new_mid.0)
.bind(new_mid.1)
.bind(new_start.0)
.bind(new_start.1)
.bind(new_end.0)
.bind(new_end.1)
.bind(mn_lat)
.bind(mx_lat)
.bind(mn_lng)
.bind(mx_lng)
.bind(user_id())
.bind(primary_id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
// 2) hide secondary, link to primary
sqlx::query(
"UPDATE assets SET
merged_into = ?,
deleted = 1,
modified = 1,
modified_by = ?,
modified_at = datetime('now')
WHERE id = ?",
)
.bind(primary_id)
.bind(user_id())
.bind(secondary_id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
// `after` carries the merged primary's geometry so redo can re-apply
// it without recomputing. Without this, redo would only re-delete the
// secondary and leave the primary at its pre-merge geometry.
let after_json = serde_json::to_string(&serde_json::json!({
"primary_id": primary_id,
"lat": new_mid.0,
"lng": new_mid.1,
"start_lat": new_start.0,
"start_lng": new_start.1,
"end_lat": new_end.0,
"end_lng": new_end.1,
"min_lat": mn_lat,
"max_lat": mx_lat,
"min_lng": mn_lng,
"max_lng": mx_lng,
}))
.map_err(|e| e.to_string())?;
let ids_json =
serde_json::to_string(&[primary_id, secondary_id]).map_err(|e| e.to_string())?;
sqlx::query(
"INSERT INTO action_history (action_type, scope, asset_ids, user_id, before, after, ts)
VALUES ('merge', 'merge', ?, ?, ?, ?, datetime('now'))",
)
.bind(&ids_json)
.bind(user_id())
.bind(&primary_before)
.bind(&after_json)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
tx.commit().await.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn undo_action(action_id: i64, state: State<'_, AppState>) -> Result<(), String> {
let row: AuditRow =
sqlx::query_as("SELECT id, action_type, scope, asset_ids, user_id, before, after, ts, undone FROM action_history WHERE id = ?")
.bind(action_id)
.fetch_one(&state.pool)
.await
.map_err(|e| e.to_string())?;
if row.undone == 1 {
return Err("action already undone".to_string());
}
let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?;
match row.action_type.as_str() {
// snap_single shares the "before" shape with move (full Asset snapshot).
"move" | "snap_single" => {
let snap: Asset = serde_json::from_str(
row.before.as_deref().unwrap_or("{}"),
)
.map_err(|e| e.to_string())?;
sqlx::query(
"UPDATE assets SET
lat = ?, lng = ?,
start_lat = ?, start_lng = ?,
end_lat = ?, end_lng = ?,
min_lat = ?, max_lat = ?,
min_lng = ?, max_lng = ?,
modified = ?, modified_by = ?, modified_at = datetime('now')
WHERE id = ?",
)
.bind(snap.lat)
.bind(snap.lng)
.bind(snap.start_lat)
.bind(snap.start_lng)
.bind(snap.end_lat)
.bind(snap.end_lng)
// Recompute bbox from snapshot
.bind(min_or_nan(&[snap.lat, snap.start_lat, snap.end_lat]))
.bind(max_or_nan(&[snap.lat, snap.start_lat, snap.end_lat]))
.bind(min_or_nan(&[snap.lng, snap.start_lng, snap.end_lng]))
.bind(max_or_nan(&[snap.lng, snap.start_lng, snap.end_lng]))
.bind(snap.modified)
.bind(user_id())
.bind(snap.id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
}
"delete" | "delete_by_video" => {
let ids: Vec<i64> = serde_json::from_str(&row.asset_ids).map_err(|e| e.to_string())?;
for id in ids {
sqlx::query("UPDATE assets SET deleted = 0, modified = 1, modified_by = ?, modified_at = datetime('now') WHERE id = ?")
.bind(user_id())
.bind(id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
}
}
"set_scope" => {
let before_map: serde_json::Map<String, serde_json::Value> =
serde_json::from_str(row.before.as_deref().unwrap_or("{}"))
.map_err(|e| e.to_string())?;
for (id_str, val) in before_map {
let id: i64 = id_str.parse().map_err(|_| "bad id".to_string())?;
let in_scope = val.get("in_scope").and_then(|v| v.as_i64());
let manual = val.get("manual_scope").and_then(|v| v.as_i64());
sqlx::query(
"UPDATE assets SET in_scope = ?, manual_scope = ? WHERE id = ?",
)
.bind(in_scope)
.bind(manual)
.bind(id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
}
}
"rename" => {
let before_map: serde_json::Map<String, serde_json::Value> =
serde_json::from_str(row.before.as_deref().unwrap_or("{}"))
.map_err(|e| e.to_string())?;
for (id_str, val) in before_map {
let id: i64 = id_str.parse().map_err(|_| "bad id".to_string())?;
let prior = val
.get("asset_name")
.and_then(|v| v.as_str())
.unwrap_or("");
sqlx::query("UPDATE assets SET asset_name = ? WHERE id = ?")
.bind(prior)
.bind(id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
}
}
"change_side" => {
let before_map: serde_json::Map<String, serde_json::Value> =
serde_json::from_str(row.before.as_deref().unwrap_or("{}"))
.map_err(|e| e.to_string())?;
for (id_str, val) in before_map {
let id: i64 = id_str.parse().map_err(|_| "bad id".to_string())?;
let prior =
val.get("side").and_then(|v| v.as_str()).map(String::from);
sqlx::query("UPDATE assets SET side = ? WHERE id = ?")
.bind(&prior)
.bind(id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
}
}
"link" => {
let before_map: serde_json::Map<String, serde_json::Value> =
serde_json::from_str(row.before.as_deref().unwrap_or("{}"))
.map_err(|e| e.to_string())?;
for (id_str, val) in before_map {
let id: i64 = id_str.parse().map_err(|_| "bad id".to_string())?;
let pair = val.get("link_pair_id").and_then(|v| v.as_i64());
let locked = val
.get("link_locked")
.and_then(|v| v.as_i64())
.unwrap_or(0);
sqlx::query(
"UPDATE assets SET link_pair_id = ?, link_locked = ? WHERE id = ?",
)
.bind(pair)
.bind(locked)
.bind(id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
}
}
"bulk_translate" => {
// Same shape as reset_position: before_map keyed by id with
// pre-edit lat/lng/start/end. Restore each row.
let before_map: serde_json::Map<String, serde_json::Value> =
serde_json::from_str(row.before.as_deref().unwrap_or("{}"))
.map_err(|e| e.to_string())?;
for (id_str, val) in before_map {
let id: i64 = id_str.parse().map_err(|_| "bad id".to_string())?;
let lat = val.get("lat").and_then(|v| v.as_f64());
let lng = val.get("lng").and_then(|v| v.as_f64());
let slat = val.get("start_lat").and_then(|v| v.as_f64());
let slng = val.get("start_lng").and_then(|v| v.as_f64());
let elat = val.get("end_lat").and_then(|v| v.as_f64());
let elng = val.get("end_lng").and_then(|v| v.as_f64());
let modified = val.get("modified").and_then(|v| v.as_i64()).unwrap_or(0);
let lats: Vec<f64> = [lat, slat, elat].into_iter().flatten().collect();
let lngs: Vec<f64> = [lng, slng, elng].into_iter().flatten().collect();
if lats.is_empty() || lngs.is_empty() {
continue;
}
let mn_lat = lats.iter().cloned().fold(f64::INFINITY, f64::min);
let mx_lat = lats.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let mn_lng = lngs.iter().cloned().fold(f64::INFINITY, f64::min);
let mx_lng = lngs.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
sqlx::query(
"UPDATE assets SET
lat = ?, lng = ?, start_lat = ?, start_lng = ?,
end_lat = ?, end_lng = ?,
min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?,
modified = ?, modified_by = ?, modified_at = datetime('now')
WHERE id = ?",
)
.bind(lat)
.bind(lng)
.bind(slat)
.bind(slng)
.bind(elat)
.bind(elng)
.bind(mn_lat)
.bind(mx_lat)
.bind(mn_lng)
.bind(mx_lng)
.bind(modified)
.bind(user_id())
.bind(id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
}
}
"reset_position" => {
let before_map: serde_json::Map<String, serde_json::Value> =
serde_json::from_str(row.before.as_deref().unwrap_or("{}"))
.map_err(|e| e.to_string())?;
for (id_str, val) in before_map {
let id: i64 = id_str.parse().map_err(|_| "bad id".to_string())?;
let lat = val.get("lat").and_then(|v| v.as_f64());
let lng = val.get("lng").and_then(|v| v.as_f64());
let slat = val.get("start_lat").and_then(|v| v.as_f64());
let slng = val.get("start_lng").and_then(|v| v.as_f64());
let elat = val.get("end_lat").and_then(|v| v.as_f64());
let elng = val.get("end_lng").and_then(|v| v.as_f64());
let modified = val.get("modified").and_then(|v| v.as_i64()).unwrap_or(0);
let lats: Vec<f64> = [lat, slat, elat].into_iter().flatten().collect();
let lngs: Vec<f64> = [lng, slng, elng].into_iter().flatten().collect();
if lats.is_empty() || lngs.is_empty() {
continue;
}
let mn_lat = lats.iter().cloned().fold(f64::INFINITY, f64::min);
let mx_lat = lats.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let mn_lng = lngs.iter().cloned().fold(f64::INFINITY, f64::min);
let mx_lng = lngs.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
sqlx::query(
"UPDATE assets SET
lat = ?, lng = ?, start_lat = ?, start_lng = ?,
end_lat = ?, end_lng = ?,
min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?,
modified = ?, modified_by = ?, modified_at = datetime('now')
WHERE id = ?",
)
.bind(lat)
.bind(lng)
.bind(slat)
.bind(slng)
.bind(elat)
.bind(elng)
.bind(mn_lat)
.bind(mx_lat)
.bind(mn_lng)
.bind(mx_lng)
.bind(modified)
.bind(user_id())
.bind(id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
}
}
"restore" => {
// Inverse of restore = re-delete.
let ids: Vec<i64> = serde_json::from_str(&row.asset_ids)
.map_err(|e| e.to_string())?;
for id in ids {
sqlx::query("UPDATE assets SET deleted = 1 WHERE id = ?")
.bind(id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
}
}
"merge" => {
// asset_ids = [primary_id, secondary_id]; before = full primary snapshot.
let ids: Vec<i64> = serde_json::from_str(&row.asset_ids).map_err(|e| e.to_string())?;
if ids.len() != 2 {
return Err("malformed merge audit row".to_string());
}
let primary_id = ids[0];
let secondary_id = ids[1];
// Restore primary geometry from snapshot.
let snap: Asset = serde_json::from_str(row.before.as_deref().unwrap_or("{}"))
.map_err(|e| e.to_string())?;
let mn_lat = min_or_nan(&[snap.lat, snap.start_lat, snap.end_lat]);
let mx_lat = max_or_nan(&[snap.lat, snap.start_lat, snap.end_lat]);
let mn_lng = min_or_nan(&[snap.lng, snap.start_lng, snap.end_lng]);
let mx_lng = max_or_nan(&[snap.lng, snap.start_lng, snap.end_lng]);
sqlx::query(
"UPDATE assets SET
lat = ?, lng = ?,
start_lat = ?, start_lng = ?, end_lat = ?, end_lng = ?,
min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?,
modified = ?, modified_by = ?, modified_at = datetime('now')
WHERE id = ?",
)
.bind(snap.lat)
.bind(snap.lng)
.bind(snap.start_lat)
.bind(snap.start_lng)
.bind(snap.end_lat)
.bind(snap.end_lng)
.bind(mn_lat)
.bind(mx_lat)
.bind(mn_lng)
.bind(mx_lng)
.bind(snap.modified)
.bind(user_id())
.bind(primary_id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
// Restore secondary's flags.
sqlx::query(
"UPDATE assets SET merged_into = NULL, deleted = 0, modified = 1,
modified_by = ?, modified_at = datetime('now')
WHERE id = ?",
)
.bind(user_id())
.bind(secondary_id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
}
"bulk_snap" => {
let before_map: serde_json::Map<String, serde_json::Value> =
serde_json::from_str(row.before.as_deref().unwrap_or("{}"))
.map_err(|e| e.to_string())?;
for (id_str, snap_val) in before_map {
let id: i64 = id_str.parse().map_err(|_| "bad id".to_string())?;
let snap: Asset =
serde_json::from_value(snap_val).map_err(|e| e.to_string())?;
let mn_lat = min_or_nan(&[snap.lat, snap.start_lat, snap.end_lat]);
let mx_lat = max_or_nan(&[snap.lat, snap.start_lat, snap.end_lat]);
let mn_lng = min_or_nan(&[snap.lng, snap.start_lng, snap.end_lng]);
let mx_lng = max_or_nan(&[snap.lng, snap.start_lng, snap.end_lng]);
sqlx::query(
"UPDATE assets SET
lat = ?, lng = ?,
start_lat = ?, start_lng = ?,
end_lat = ?, end_lng = ?,
min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?,
modified = ?, modified_by = ?, modified_at = datetime('now')
WHERE id = ?",
)
.bind(snap.lat)
.bind(snap.lng)
.bind(snap.start_lat)
.bind(snap.start_lng)
.bind(snap.end_lat)
.bind(snap.end_lng)
.bind(mn_lat)
.bind(mx_lat)
.bind(mn_lng)
.bind(mx_lng)
.bind(snap.modified)
.bind(user_id())
.bind(id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
}
}
other => return Err(format!("unknown action_type: {}", other)),
}
sqlx::query("UPDATE action_history SET undone = 1 WHERE id = ?")
.bind(action_id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
tx.commit().await.map_err(|e| e.to_string())?;
apply_scope_after_change(&state.pool).await?;
Ok(())
}
fn min_or_nan(vals: &[Option<f64>]) -> f64 {
vals.iter()
.filter_map(|v| *v)
.fold(f64::INFINITY, f64::min)
}
fn max_or_nan(vals: &[Option<f64>]) -> f64 {
vals.iter()
.filter_map(|v| *v)
.fold(f64::NEG_INFINITY, f64::max)
}
#[derive(Debug, Deserialize)]
struct GeoJsonFeatureCollection {
features: Vec<GeoJsonFeature>,
}
#[derive(Debug, Deserialize)]
struct GeoJsonFeature {
geometry: serde_json::Value,
#[serde(default)]
properties: serde_json::Value,
}
#[derive(Debug, Serialize)]
pub struct RoadOut {
pub id: i64,
pub name: Option<String>,
pub geojson: String,
pub snap_ignored: bool,
// True if the road's geometry has been touched since import. Drives the
// green highlight in the OSM layer so users can see what they've changed.
pub modified: bool,
}
// Generic over the executor: pass &state.pool for reads taken before any
// mutation, or &mut *tx for reads that must see the in-flight UPDATE inside
// the same transaction. Without this, a 4-connection pool reads a different
// connection that hasn't observed the uncommitted write yet, producing
// stale `after` snapshots and broken redo.
// Takes &mut SqliteConnection so callers can reborrow inside chunks.
// Pool callers pass &mut *(pool.acquire().await?); tx callers pass &mut *tx.
// This lets in-tx snapshots see the UPDATE that's already happened in the
// same transaction (the previous &state.pool path read a *different*
// pool-connection that hadn't observed the uncommitted write).
// Chunked at 500 to stay clear of SQLite's 999 bind-parameter ceiling.
async fn snapshot_scope_state(
conn: &mut sqlx::SqliteConnection,
ids: &[i64],
) -> Result<serde_json::Map<String, serde_json::Value>, String> {
let mut m = serde_json::Map::new();
if ids.is_empty() {
return Ok(m);
}
for chunk in ids.chunks(500) {
let placeholders = vec!["?"; chunk.len()].join(",");
let sql = format!(
"SELECT id, in_scope, manual_scope FROM assets WHERE id IN ({})",
placeholders
);
let mut q = sqlx::query_as::<_, (i64, Option<i64>, Option<i64>)>(&sql);
for id in chunk {
q = q.bind(id);
}
let rows = q.fetch_all(&mut *conn).await.map_err(|e| e.to_string())?;
for (id, in_scope, manual) in rows {
m.insert(
id.to_string(),
serde_json::json!({"in_scope": in_scope, "manual_scope": manual}),
);
}
}
Ok(m)
}
#[tauri::command]
pub async fn set_assets_in_scope(
ids: Vec<i64>,
in_scope: bool,
state: State<'_, AppState>,
) -> Result<usize, String> {
if ids.is_empty() {
return Ok(0);
}
let mut conn = state.pool.acquire().await.map_err(|e| e.to_string())?;
let before = snapshot_scope_state(&mut conn, &ids).await?;
drop(conn);
let placeholders = vec!["?"; ids.len()].join(",");
let sql = format!(
"UPDATE assets SET in_scope = ?, manual_scope = ?, modified_by = ?, \
modified_at = datetime('now') WHERE id IN ({})",
placeholders
);
let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?;
let mut q = sqlx::query(&sql)
.bind(in_scope as i64)
.bind(in_scope as i64)
.bind(user_id());
for id in &ids {
q = q.bind(id);
}
let res = q.execute(&mut *tx).await.map_err(|e| e.to_string())?;
// Read `after` from inside the same tx so the snapshot reflects the
// UPDATE we just executed.
let after = snapshot_scope_state(&mut *tx, &ids).await?;
let ids_json = serde_json::to_string(&ids).map_err(|e| e.to_string())?;
let before_json = serde_json::to_string(&before).map_err(|e| e.to_string())?;
let after_json = serde_json::to_string(&after).map_err(|e| e.to_string())?;
sqlx::query(
"INSERT INTO action_history (action_type, scope, asset_ids, user_id, before, after, ts)
VALUES ('set_scope', 'bulk', ?, ?, ?, ?, datetime('now'))",
)
.bind(&ids_json)
.bind(user_id())
.bind(&before_json)
.bind(&after_json)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
tx.commit().await.map_err(|e| e.to_string())?;
Ok(res.rows_affected() as usize)
}
#[tauri::command]
pub async fn clear_manual_scope(
ids: Vec<i64>,
state: State<'_, AppState>,
) -> Result<usize, String> {
if ids.is_empty() {
return Ok(0);
}
let mut conn = state.pool.acquire().await.map_err(|e| e.to_string())?;
let before = snapshot_scope_state(&mut conn, &ids).await?;
drop(conn);
let placeholders = vec!["?"; ids.len()].join(",");
let sql = format!(
"UPDATE assets SET manual_scope = NULL WHERE id IN ({})",
placeholders
);
let mut q = sqlx::query(&sql);
for id in &ids {
q = q.bind(id);
}
let res = q.execute(&state.pool).await.map_err(|e| e.to_string())?;
apply_scope_after_change(&state.pool).await?;
let mut conn = state.pool.acquire().await.map_err(|e| e.to_string())?;
let after = snapshot_scope_state(&mut conn, &ids).await?;
drop(conn);
let ids_json = serde_json::to_string(&ids).map_err(|e| e.to_string())?;
let before_json = serde_json::to_string(&before).map_err(|e| e.to_string())?;
let after_json = serde_json::to_string(&after).map_err(|e| e.to_string())?;
sqlx::query(
"INSERT INTO action_history (action_type, scope, asset_ids, user_id, before, after, ts)
VALUES ('set_scope', 'bulk', ?, ?, ?, ?, datetime('now'))",
)
.bind(&ids_json)
.bind(user_id())
.bind(&before_json)
.bind(&after_json)
.execute(&state.pool)
.await
.map_err(|e| e.to_string())?;
Ok(res.rows_affected() as usize)
}
#[tauri::command]
pub async fn set_assets_by_video_in_scope(
video_name: String,
in_scope: bool,
state: State<'_, AppState>,
) -> Result<usize, String> {
let rows: Vec<(i64,)> = sqlx::query_as(
"SELECT id FROM assets WHERE video_name = ? AND deleted = 0",
)
.bind(&video_name)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
let ids: Vec<i64> = rows.into_iter().map(|(id,)| id).collect();
set_assets_in_scope(ids, in_scope, state).await
}
#[tauri::command]
pub async fn delete_assets_bulk(
ids: Vec<i64>,
state: State<'_, AppState>,
) -> Result<usize, String> {
if ids.is_empty() {
return Ok(0);
}
// SQLite caps bind params at 999; chunk into batches.
let mut assets: Vec<Asset> = Vec::with_capacity(ids.len());
for chunk in ids.chunks(500) {
let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(",");
let before_sql = format!(
"SELECT id, row_id, asset_type, asset_name, video_name, lat, lng, \
start_lat, start_lng, end_lat, end_lng, \
image_path, image_path1, image_path2, image_path3, \
deleted, modified, merged_into, in_scope, side, \
link_pair_id, link_locked \
FROM assets WHERE id IN ({}) AND deleted = 0",
placeholders
);
let mut bq = sqlx::query_as::<_, Asset>(&before_sql);
for id in chunk {
bq = bq.bind(id);
}
let part = bq.fetch_all(&state.pool).await.map_err(|e| e.to_string())?;
assets.extend(part);
}
if assets.is_empty() {
return Ok(0);
}
let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?;
let mut total_affected: u64 = 0;
for chunk in ids.chunks(500) {
let placeholders = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(",");
let upd_sql = format!(
"UPDATE assets SET deleted = 1, modified = 1, modified_by = ?, \
modified_at = datetime('now') WHERE id IN ({}) AND deleted = 0",
placeholders
);
let mut uq = sqlx::query(&upd_sql).bind(user_id());
for id in chunk {
uq = uq.bind(id);
}
let r = uq.execute(&mut *tx).await.map_err(|e| e.to_string())?;
total_affected += r.rows_affected();
}
let affected_ids: Vec<i64> = assets.iter().map(|a| a.id).collect();
let mut before_map = serde_json::Map::new();
for a in &assets {
before_map.insert(
a.id.to_string(),
serde_json::to_value(a).unwrap_or(serde_json::Value::Null),
);
}
let ids_json = serde_json::to_string(&affected_ids).map_err(|e| e.to_string())?;
let before_json = serde_json::to_string(&before_map).map_err(|e| e.to_string())?;
sqlx::query(
"INSERT INTO action_history (action_type, scope, asset_ids, user_id, before, ts)
VALUES ('delete', 'bulk', ?, ?, ?, datetime('now'))",
)
.bind(&ids_json)
.bind(user_id())
.bind(&before_json)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
tx.commit().await.map_err(|e| e.to_string())?;
Ok(total_affected as usize)
}
#[tauri::command]
pub async fn export_assets(
path: String,
format: String,
state: State<'_, AppState>,
) -> Result<usize, String> {
let assets: Vec<Asset> = sqlx::query_as(
"SELECT id, row_id, asset_type, asset_name, video_name,
lat, lng, start_lat, start_lng, end_lat, end_lng,
image_path, image_path1, image_path2, image_path3,
deleted, modified, merged_into, in_scope, side, link_pair_id, link_locked
FROM assets WHERE deleted = 0 ORDER BY asset_type, asset_name, id",
)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
let body = match format.to_lowercase().as_str() {
"geojson" | "json" => {
let mut features = vec![];
for a in &assets {
let props = serde_json::json!({
"row_id": a.row_id,
"asset_type": a.asset_type,
"asset_name": a.asset_name,
"video_name": a.video_name,
"side": a.side,
"in_scope": a.in_scope,
"modified": a.modified,
"image_path": a.image_path,
"image_path1": a.image_path1,
"image_path2": a.image_path2,
"image_path3": a.image_path3,
"link_pair_id": a.link_pair_id,
"link_locked": a.link_locked,
});
if a.asset_type == "fixed" {
if let (Some(la), Some(ln)) = (a.lat, a.lng) {
features.push(serde_json::json!({
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [ln, la]},
"properties": props,
}));
}
} else if a.asset_type == "range" {
let mut coords: Vec<[f64; 2]> = vec![];
if let (Some(la), Some(ln)) = (a.start_lat, a.start_lng) {
coords.push([ln, la]);
}
if let (Some(la), Some(ln)) = (a.lat, a.lng) {
coords.push([ln, la]);
}
if let (Some(la), Some(ln)) = (a.end_lat, a.end_lng) {
coords.push([ln, la]);
}
if coords.len() >= 2 {
features.push(serde_json::json!({
"type": "Feature",
"geometry": {"type": "LineString", "coordinates": coords},
"properties": props,
}));
}
}
}
serde_json::to_string_pretty(&serde_json::json!({
"type": "FeatureCollection",
"features": features,
}))
.map_err(|e| e.to_string())?
}
"kml" => {
let mut s = String::new();
s.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
s.push_str("<kml xmlns=\"http://www.opengis.net/kml/2.2\"><Document>\n");
for a in &assets {
let name = format!("{} · {}", a.asset_name, a.row_id);
let desc = format!(
"type: {}\nvideo: {}\nside: {}\nmodified: {}",
a.asset_type,
a.video_name,
a.side.as_deref().unwrap_or(""),
a.modified
);
if a.asset_type == "fixed" {
if let (Some(la), Some(ln)) = (a.lat, a.lng) {
s.push_str(&format!(
"<Placemark><name>{}</name><description>{}</description>\
<Point><coordinates>{},{},0</coordinates></Point></Placemark>\n",
xml_escape(&name),
xml_escape(&desc),
ln,
la
));
}
} else if a.asset_type == "range" {
let mut coords: Vec<(f64, f64)> = vec![];
if let (Some(la), Some(ln)) = (a.start_lat, a.start_lng) {
coords.push((ln, la));
}
if let (Some(la), Some(ln)) = (a.lat, a.lng) {
coords.push((ln, la));
}
if let (Some(la), Some(ln)) = (a.end_lat, a.end_lng) {
coords.push((ln, la));
}
if coords.len() >= 2 {
let coord_str = coords
.iter()
.map(|(ln, la)| format!("{},{},0", ln, la))
.collect::<Vec<_>>()
.join(" ");
s.push_str(&format!(
"<Placemark><name>{}</name><description>{}</description>\
<LineString><coordinates>{}</coordinates></LineString></Placemark>\n",
xml_escape(&name),
xml_escape(&desc),
coord_str
));
}
}
}
s.push_str("</Document></kml>\n");
s
}
"source" => {
// Round-trippable JSON in the same shape import_*_assets accept.
// Fixed and range objects are written into separate top-level keys
// so the user can re-feed each via the matching importer. Includes
// deleted=true rows so the audit state survives a round-trip.
let mut fixed_arr: Vec<serde_json::Value> = vec![];
let mut range_arr: Vec<serde_json::Value> = vec![];
for a in &assets {
if a.asset_type == "fixed" {
if let (Some(la), Some(ln)) = (a.lat, a.lng) {
fixed_arr.push(serde_json::json!({
"row_id": a.row_id,
"asset_name": a.asset_name,
"video_name": a.video_name,
"image_path": a.image_path,
"coord": [la, ln],
"deleted": a.deleted != 0,
"side": a.side,
}));
}
} else if a.asset_type == "range" {
if let (Some(la), Some(ln), Some(sla), Some(sln), Some(ela), Some(eln)) =
(a.lat, a.lng, a.start_lat, a.start_lng, a.end_lat, a.end_lng)
{
range_arr.push(serde_json::json!({
"row_id": a.row_id,
"asset_name": a.asset_name,
"video_name": a.video_name,
"coord_lat": la,
"coord_lng": ln,
"start_coord_lat": sla,
"start_coord_lng": sln,
"end_coord_lat": ela,
"end_coord_lng": eln,
"image_path1": a.image_path1,
"image_path2": a.image_path2,
"image_path3": a.image_path3,
"deleted": a.deleted != 0,
"side": a.side,
}));
}
}
}
serde_json::to_string_pretty(&serde_json::json!({
"fixed": fixed_arr,
"range": range_arr,
}))
.map_err(|e| e.to_string())?
}
"csv" => {
let mut s = String::new();
s.push_str("id,row_id,asset_type,asset_name,video_name,side,in_scope,deleted,modified,lat,lng,start_lat,start_lng,end_lat,end_lng,link_pair_id,link_locked,image_path,image_path1,image_path2,image_path3\n");
let csv_q = |v: Option<&str>| -> String {
let s = v.unwrap_or("");
if s.contains(',') || s.contains('"') || s.contains('\n') {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
};
let f = |o: Option<f64>| o.map(|v| v.to_string()).unwrap_or_default();
let i = |o: Option<i64>| o.map(|v| v.to_string()).unwrap_or_default();
for a in &assets {
s.push_str(&format!(
"{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}\n",
a.id,
csv_q(Some(&a.row_id)),
a.asset_type,
csv_q(Some(&a.asset_name)),
csv_q(Some(&a.video_name)),
csv_q(a.side.as_deref()),
i(a.in_scope),
a.deleted,
a.modified,
f(a.lat),
f(a.lng),
f(a.start_lat),
f(a.start_lng),
f(a.end_lat),
f(a.end_lng),
i(a.link_pair_id),
i(a.link_locked),
csv_q(a.image_path.as_deref()),
csv_q(a.image_path1.as_deref()),
csv_q(a.image_path2.as_deref()),
csv_q(a.image_path3.as_deref()),
));
}
s
}
other => return Err(format!("unsupported format: {}", other)),
};
tokio::fs::write(&path, body)
.await
.map_err(|e| e.to_string())?;
Ok(assets.len())
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
#[tauri::command]
pub async fn list_classes(state: State<'_, AppState>) -> Result<Vec<String>, String> {
// Read classes.txt from app dir if present; otherwise fall back to the
// distinct asset_name list. Frontend uses this to populate the rename
// dropdown.
let path = std::env::current_dir()
.map_err(|e| e.to_string())?
.join("classes.txt");
if path.exists() {
let bytes = tokio::fs::read(&path).await.map_err(|e| e.to_string())?;
let s = String::from_utf8_lossy(&bytes);
let names: Vec<String> = s
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.collect();
if !names.is_empty() {
return Ok(names);
}
}
let rows: Vec<(String,)> = sqlx::query_as(
"SELECT DISTINCT asset_name FROM assets WHERE deleted = 0 ORDER BY asset_name",
)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
Ok(rows.into_iter().map(|(n,)| n).collect())
}
#[tauri::command]
pub async fn add_class(name: String) -> Result<(), String> {
let path = std::env::current_dir()
.map_err(|e| e.to_string())?
.join("classes.txt");
let trimmed = name.trim().to_string();
if trimmed.is_empty() {
return Err("class name is empty".into());
}
let mut existing = if path.exists() {
let bytes = tokio::fs::read(&path).await.map_err(|e| e.to_string())?;
String::from_utf8_lossy(&bytes).to_string()
} else {
String::new()
};
let already = existing.lines().any(|l| l.trim() == trimmed);
if already {
return Ok(());
}
if !existing.is_empty() && !existing.ends_with('\n') {
existing.push('\n');
}
existing.push_str(&trimmed);
existing.push('\n');
tokio::fs::write(&path, existing)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[derive(Debug, Serialize)]
pub struct AutoLinkResult {
pub linked: usize,
pub leftover_a: usize,
pub leftover_b: usize,
}
// Same shape as snapshot_scope_state — see notes there.
async fn snapshot_link_state(
conn: &mut sqlx::SqliteConnection,
ids: &[i64],
) -> Result<serde_json::Map<String, serde_json::Value>, String> {
let mut m = serde_json::Map::new();
if ids.is_empty() {
return Ok(m);
}
for chunk in ids.chunks(500) {
let placeholders = vec!["?"; chunk.len()].join(",");
let sql = format!(
"SELECT id, link_pair_id, link_locked FROM assets WHERE id IN ({})",
placeholders
);
let mut q = sqlx::query_as::<_, (i64, Option<i64>, Option<i64>)>(&sql);
for id in chunk {
q = q.bind(id);
}
let rows = q.fetch_all(&mut *conn).await.map_err(|e| e.to_string())?;
for (id, pair, locked) in rows {
m.insert(
id.to_string(),
serde_json::json!({"link_pair_id": pair, "link_locked": locked.unwrap_or(0)}),
);
}
}
Ok(m)
}
fn haversine_m(a: (f64, f64), b: (f64, f64)) -> f64 {
let r = 6_371_000.0_f64;
let to_rad = |d: f64| d * std::f64::consts::PI / 180.0;
let d_lat = to_rad(b.0 - a.0);
let d_lng = to_rad(b.1 - a.1);
let s = (d_lat / 2.0).sin().powi(2)
+ to_rad(a.0).cos() * to_rad(b.0).cos() * (d_lng / 2.0).sin().powi(2);
2.0 * r * s.sqrt().asin()
}
fn point_in_polygon(lng: f64, lat: f64, verts: &[[f64; 2]]) -> bool {
if verts.len() < 3 {
return false;
}
let mut inside = false;
let n = verts.len();
let mut j = n - 1;
for i in 0..n {
let xi = verts[i][0];
let yi = verts[i][1];
let xj = verts[j][0];
let yj = verts[j][1];
let intersect = (yi > lat) != (yj > lat)
&& lng < (xj - xi) * (lat - yi) / (yj - yi + 1e-12) + xi;
if intersect {
inside = !inside;
}
j = i;
}
inside
}
#[tauri::command]
pub async fn auto_link_in_polygon(
polygon: Vec<[f64; 2]>,
split_by: String,
max_distance_m: f64,
state: State<'_, AppState>,
) -> Result<AutoLinkResult, String> {
if polygon.len() < 3 {
return Err("polygon needs ≥ 3 vertices".into());
}
if max_distance_m <= 0.0 {
return Err("max_distance_m must be > 0".into());
}
let mn_lat = polygon.iter().map(|p| p[1]).fold(f64::INFINITY, f64::min);
let mx_lat = polygon.iter().map(|p| p[1]).fold(f64::NEG_INFINITY, f64::max);
let mn_lng = polygon.iter().map(|p| p[0]).fold(f64::INFINITY, f64::min);
let mx_lng = polygon.iter().map(|p| p[0]).fold(f64::NEG_INFINITY, f64::max);
let candidates: Vec<Asset> = sqlx::query_as(
"SELECT a.id, a.row_id, a.asset_type, a.asset_name, a.video_name,
a.lat, a.lng, a.start_lat, a.start_lng, a.end_lat, a.end_lng,
a.image_path, a.image_path1, a.image_path2, a.image_path3,
a.deleted, a.modified, a.merged_into, a.in_scope, a.side, a.link_pair_id, a.link_locked
FROM assets a JOIN assets_rtree r ON r.id = a.id
WHERE r.max_lat >= ? AND r.min_lat <= ?
AND r.max_lng >= ? AND r.min_lng <= ?
AND a.deleted = 0",
)
.bind(mn_lat)
.bind(mx_lat)
.bind(mn_lng)
.bind(mx_lng)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
let inside: Vec<&Asset> = candidates
.iter()
.filter(|a| match (a.lat, a.lng) {
(Some(la), Some(lo)) => point_in_polygon(lo, la, &polygon),
_ => false,
})
.collect();
let split_key = |a: &Asset| -> String {
match split_by.as_str() {
"video" => a.video_name.clone(),
_ => a
.side
.clone()
.map(|s| s.to_uppercase())
.unwrap_or_else(|| "_".to_string()),
}
};
let pool: Vec<&Asset> = inside
.iter()
.filter(|a| a.link_locked.unwrap_or(0) != 1)
.copied()
.collect();
let (matched, leftover_a, leftover_b): (Vec<(i64, i64)>, usize, usize) =
if split_by == "none" {
// Pair nearest neighbours regardless of side/video. Useful when
// video_name is missing or unreliable.
let n = pool.len();
let mut p: Vec<(usize, usize, f64)> = Vec::with_capacity(n * n / 2);
for i in 0..n {
let (la, lo) = (pool[i].lat.unwrap(), pool[i].lng.unwrap());
for j in (i + 1)..n {
let (lb, lob) = (pool[j].lat.unwrap(), pool[j].lng.unwrap());
let d = haversine_m((la, lo), (lb, lob));
if d <= max_distance_m {
p.push((i, j, d));
}
}
}
p.sort_by(|x, y| x.2.partial_cmp(&y.2).unwrap_or(std::cmp::Ordering::Equal));
let mut used = vec![false; n];
let mut m: Vec<(i64, i64)> = vec![];
for (i, j, _) in &p {
if used[*i] || used[*j] {
continue;
}
used[*i] = true;
used[*j] = true;
m.push((pool[*i].id, pool[*j].id));
}
let leftover = used.iter().filter(|x| !**x).count();
(m, leftover, 0)
} else {
use std::collections::HashMap;
let mut buckets: HashMap<String, Vec<&Asset>> = HashMap::new();
for a in &pool {
buckets.entry(split_key(a)).or_default().push(a);
}
let mut groups: Vec<(String, Vec<&Asset>)> = buckets.into_iter().collect();
groups.sort_by(|a, b| b.1.len().cmp(&a.1.len()));
if groups.len() < 2 {
return Ok(AutoLinkResult {
linked: 0,
leftover_a: groups.first().map(|g| g.1.len()).unwrap_or(0),
leftover_b: 0,
});
}
let group_a = groups[0].1.clone();
let group_b = groups[1].1.clone();
let mut p: Vec<(usize, usize, f64)> =
Vec::with_capacity(group_a.len() * group_b.len());
for (i, a) in group_a.iter().enumerate() {
let (la, lo) = (a.lat.unwrap(), a.lng.unwrap());
for (j, b) in group_b.iter().enumerate() {
let (lb, lob) = (b.lat.unwrap(), b.lng.unwrap());
let d = haversine_m((la, lo), (lb, lob));
if d <= max_distance_m {
p.push((i, j, d));
}
}
}
p.sort_by(|x, y| x.2.partial_cmp(&y.2).unwrap_or(std::cmp::Ordering::Equal));
let mut used_a = vec![false; group_a.len()];
let mut used_b = vec![false; group_b.len()];
let mut m: Vec<(i64, i64)> = vec![];
for (i, j, _) in &p {
if used_a[*i] || used_b[*j] {
continue;
}
used_a[*i] = true;
used_b[*j] = true;
m.push((group_a[*i].id, group_b[*j].id));
}
let la = used_a.iter().filter(|x| !**x).count();
let lb = used_b.iter().filter(|x| !**x).count();
(m, la, lb)
};
let mut affected_ids: Vec<i64> = vec![];
for (a, b) in &matched {
affected_ids.push(*a);
affected_ids.push(*b);
}
// Pull in any *prior* partners of the matched ids — they get cleared by
// the orphan UPDATE below, so they must appear in `before` for undo to
// restore them. Without this, undo of an auto-link leaves the previous
// partners with link_pair_id = NULL forever.
if !affected_ids.is_empty() {
let placeholders = vec!["?"; affected_ids.len()].join(",");
let prior_sql = format!(
"SELECT link_pair_id FROM assets
WHERE id IN ({}) AND link_pair_id IS NOT NULL",
placeholders
);
let mut q = sqlx::query_as::<_, (Option<i64>,)>(&prior_sql);
for id in &affected_ids {
q = q.bind(id);
}
let prior_partners = q
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
for (pid_opt,) in prior_partners {
if let Some(pid) = pid_opt {
if !affected_ids.contains(&pid) {
affected_ids.push(pid);
}
}
}
}
let mut conn = state.pool.acquire().await.map_err(|e| e.to_string())?;
let before = snapshot_link_state(&mut conn, &affected_ids).await?;
drop(conn);
let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?;
// Orphan any prior partners of the matched ids (only if they were unlocked).
for id in &affected_ids {
sqlx::query(
"UPDATE assets SET link_pair_id = NULL, link_locked = 0
WHERE id IN (SELECT link_pair_id FROM assets WHERE id = ?) AND link_locked = 0",
)
.bind(id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
}
for (a, b) in &matched {
sqlx::query("UPDATE assets SET link_pair_id = ?, link_locked = 0 WHERE id = ?")
.bind(b)
.bind(a)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
sqlx::query("UPDATE assets SET link_pair_id = ?, link_locked = 0 WHERE id = ?")
.bind(a)
.bind(b)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
}
// Read in-tx so snapshot reflects the link UPDATEs we just ran.
let after = snapshot_link_state(&mut *tx, &affected_ids).await?;
let ids_json = serde_json::to_string(&affected_ids).map_err(|e| e.to_string())?;
sqlx::query(
"INSERT INTO action_history (action_type, scope, asset_ids, user_id, before, after, ts)
VALUES ('link', 'bulk', ?, ?, ?, ?, datetime('now'))",
)
.bind(&ids_json)
.bind(user_id())
.bind(serde_json::to_string(&before).map_err(|e| e.to_string())?)
.bind(serde_json::to_string(&after).map_err(|e| e.to_string())?)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
tx.commit().await.map_err(|e| e.to_string())?;
Ok(AutoLinkResult {
linked: matched.len(),
leftover_a,
leftover_b,
})
}
#[tauri::command]
pub async fn set_link(
a_id: i64,
b_id: i64,
state: State<'_, AppState>,
) -> Result<(), String> {
if a_id == b_id {
return Err("can't link an asset to itself".into());
}
let prev: Vec<(i64, Option<i64>)> =
sqlx::query_as("SELECT id, link_pair_id FROM assets WHERE id IN (?, ?)")
.bind(a_id)
.bind(b_id)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
let mut all_ids = vec![a_id, b_id];
for (_, p) in &prev {
if let Some(pid) = p {
if !all_ids.contains(pid) {
all_ids.push(*pid);
}
}
}
let mut conn = state.pool.acquire().await.map_err(|e| e.to_string())?;
let before = snapshot_link_state(&mut conn, &all_ids).await?;
drop(conn);
let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?;
for (_, p) in &prev {
if let Some(pid) = p {
sqlx::query("UPDATE assets SET link_pair_id = NULL, link_locked = 0 WHERE id = ?")
.bind(pid)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
}
}
sqlx::query("UPDATE assets SET link_pair_id = ?, link_locked = 1 WHERE id = ?")
.bind(b_id)
.bind(a_id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
sqlx::query("UPDATE assets SET link_pair_id = ?, link_locked = 1 WHERE id = ?")
.bind(a_id)
.bind(b_id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
let after = snapshot_link_state(&mut *tx, &all_ids).await?;
sqlx::query(
"INSERT INTO action_history (action_type, scope, asset_ids, user_id, before, after, ts)
VALUES ('link', 'single', ?, ?, ?, ?, datetime('now'))",
)
.bind(serde_json::to_string(&all_ids).map_err(|e| e.to_string())?)
.bind(user_id())
.bind(serde_json::to_string(&before).map_err(|e| e.to_string())?)
.bind(serde_json::to_string(&after).map_err(|e| e.to_string())?)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
tx.commit().await.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn clear_link(id: i64, state: State<'_, AppState>) -> Result<(), String> {
let row: (Option<i64>,) =
sqlx::query_as("SELECT link_pair_id FROM assets WHERE id = ?")
.bind(id)
.fetch_one(&state.pool)
.await
.map_err(|e| e.to_string())?;
let mut all_ids = vec![id];
if let Some(p) = row.0 {
all_ids.push(p);
}
let mut conn = state.pool.acquire().await.map_err(|e| e.to_string())?;
let before = snapshot_link_state(&mut conn, &all_ids).await?;
drop(conn);
let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?;
for aid in &all_ids {
sqlx::query("UPDATE assets SET link_pair_id = NULL, link_locked = 0 WHERE id = ?")
.bind(aid)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
}
let after = snapshot_link_state(&mut *tx, &all_ids).await?;
sqlx::query(
"INSERT INTO action_history (action_type, scope, asset_ids, user_id, before, after, ts)
VALUES ('link', 'single', ?, ?, ?, ?, datetime('now'))",
)
.bind(serde_json::to_string(&all_ids).map_err(|e| e.to_string())?)
.bind(user_id())
.bind(serde_json::to_string(&before).map_err(|e| e.to_string())?)
.bind(serde_json::to_string(&after).map_err(|e| e.to_string())?)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
tx.commit().await.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn change_assets_side(
ids: Vec<i64>,
side: String,
state: State<'_, AppState>,
) -> Result<usize, String> {
if ids.is_empty() {
return Ok(0);
}
let normalized = match side.trim().to_uppercase().as_str() {
"LEFT" | "LHS" | "L" => "Left",
"RIGHT" | "RHS" | "R" => "Right",
_ => return Err(format!("invalid side: {}", side)),
};
// Chunked at 500 to stay under SQLite's 999 bind-param ceiling.
let mut before_map = serde_json::Map::new();
for chunk in ids.chunks(500) {
let ph = vec!["?"; chunk.len()].join(",");
let before_sql = format!(
"SELECT id, side FROM assets WHERE id IN ({})",
ph
);
let mut bq = sqlx::query_as::<_, (i64, Option<String>)>(&before_sql);
for id in chunk {
bq = bq.bind(id);
}
let prior =
bq.fetch_all(&state.pool).await.map_err(|e| e.to_string())?;
for (id, side_v) in prior {
before_map.insert(id.to_string(), serde_json::json!({"side": side_v}));
}
}
let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?;
let mut total_affected: u64 = 0;
for chunk in ids.chunks(500) {
let ph = vec!["?"; chunk.len()].join(",");
let upd_sql = format!(
"UPDATE assets SET side = ?, modified = 1, modified_by = ?, \
modified_at = datetime('now') WHERE id IN ({})",
ph
);
let mut q = sqlx::query(&upd_sql).bind(normalized).bind(user_id());
for id in chunk {
q = q.bind(id);
}
total_affected += q
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?
.rows_affected();
}
// Mimic the previous return-by-rows-affected shape.
let res = total_affected;
let after = serde_json::json!({"side": normalized});
let ids_json = serde_json::to_string(&ids).map_err(|e| e.to_string())?;
let before_json = serde_json::to_string(&before_map).map_err(|e| e.to_string())?;
let after_json = serde_json::to_string(&after).map_err(|e| e.to_string())?;
sqlx::query(
"INSERT INTO action_history (action_type, scope, asset_ids, user_id, before, after, ts)
VALUES ('change_side', 'bulk', ?, ?, ?, ?, datetime('now'))",
)
.bind(&ids_json)
.bind(user_id())
.bind(&before_json)
.bind(&after_json)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
tx.commit().await.map_err(|e| e.to_string())?;
Ok(res as usize)
}
#[tauri::command]
pub async fn rename_assets(
ids: Vec<i64>,
asset_name: String,
state: State<'_, AppState>,
) -> Result<usize, String> {
if ids.is_empty() {
return Ok(0);
}
let trimmed = asset_name.trim().to_string();
if trimmed.is_empty() {
return Err("asset_name is empty".into());
}
// Chunked at 500 to stay under SQLite's 999 bind-param ceiling.
let mut before_map = serde_json::Map::new();
for chunk in ids.chunks(500) {
let ph = vec!["?"; chunk.len()].join(",");
let before_sql = format!(
"SELECT id, asset_name FROM assets WHERE id IN ({})",
ph
);
let mut bq = sqlx::query_as::<_, (i64, String)>(&before_sql);
for id in chunk {
bq = bq.bind(id);
}
let prior = bq
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
for (id, name) in prior {
before_map.insert(id.to_string(), serde_json::json!({"asset_name": name}));
}
}
let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?;
let mut total_affected: u64 = 0;
for chunk in ids.chunks(500) {
let ph = vec!["?"; chunk.len()].join(",");
let upd_sql = format!(
"UPDATE assets SET asset_name = ?, modified = 1, modified_by = ?, \
modified_at = datetime('now') WHERE id IN ({})",
ph
);
let mut q = sqlx::query(&upd_sql).bind(&trimmed).bind(user_id());
for id in chunk {
q = q.bind(id);
}
total_affected += q
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?
.rows_affected();
}
let res = total_affected;
let after = serde_json::json!({"asset_name": trimmed});
let ids_json = serde_json::to_string(&ids).map_err(|e| e.to_string())?;
let before_json = serde_json::to_string(&before_map).map_err(|e| e.to_string())?;
let after_json = serde_json::to_string(&after).map_err(|e| e.to_string())?;
sqlx::query(
"INSERT INTO action_history (action_type, scope, asset_ids, user_id, before, after, ts)
VALUES ('rename', 'bulk', ?, ?, ?, ?, datetime('now'))",
)
.bind(&ids_json)
.bind(user_id())
.bind(&before_json)
.bind(&after_json)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
tx.commit().await.map_err(|e| e.to_string())?;
Ok(res as usize)
}
#[derive(Debug, Serialize)]
pub struct DuplicateMember {
pub id: i64,
pub row_id: String,
pub asset_name: String,
pub video_name: String,
pub side: Option<String>,
pub lat: Option<f64>,
pub lng: Option<f64>,
pub asset_type: String,
pub start_lat: Option<f64>,
pub start_lng: Option<f64>,
pub end_lat: Option<f64>,
pub end_lng: Option<f64>,
}
#[derive(Debug, Serialize)]
pub struct DuplicateCluster {
pub id: usize,
pub asset_name: String,
pub members: Vec<DuplicateMember>,
}
#[tauri::command]
pub async fn find_duplicate_clusters(
eps_m: f64,
min_lat: Option<f64>,
max_lat: Option<f64>,
min_lng: Option<f64>,
max_lng: Option<f64>,
cross_video_only: Option<bool>,
state: State<'_, AppState>,
) -> Result<Vec<DuplicateCluster>, String> {
let cross_only = cross_video_only.unwrap_or(false);
if eps_m <= 0.0 {
return Err("eps_m must be > 0".into());
}
let assets: Vec<Asset> = if let (Some(a), Some(b), Some(c), Some(d)) =
(min_lat, max_lat, min_lng, max_lng)
{
sqlx::query_as(
"SELECT a.id, a.row_id, a.asset_type, a.asset_name, a.video_name,
a.lat, a.lng, a.start_lat, a.start_lng, a.end_lat, a.end_lng,
a.image_path, a.image_path1, a.image_path2, a.image_path3,
a.deleted, a.modified, a.merged_into, a.in_scope, a.side, a.link_pair_id, a.link_locked
FROM assets a JOIN assets_rtree r ON r.id = a.id
WHERE r.max_lat >= ? AND r.min_lat <= ?
AND r.max_lng >= ? AND r.min_lng <= ?
AND a.deleted = 0",
)
.bind(a)
.bind(b)
.bind(c)
.bind(d)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?
} else {
sqlx::query_as(
"SELECT id, row_id, asset_type, asset_name, video_name,
lat, lng, start_lat, start_lng, end_lat, end_lng,
image_path, image_path1, image_path2, image_path3,
deleted, modified, merged_into, in_scope, side, link_pair_id, link_locked
FROM assets WHERE deleted = 0",
)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?
};
use std::collections::HashMap;
let mut buckets: HashMap<(String, String), Vec<&Asset>> = HashMap::new();
for a in &assets {
if a.lat.is_none() || a.lng.is_none() {
continue;
}
let side = a.side.clone().unwrap_or_default();
buckets
.entry((a.asset_name.clone(), side))
.or_default()
.push(a);
}
fn find(parent: &mut [usize], x: usize) -> usize {
let mut r = x;
while parent[r] != r {
r = parent[r];
}
let mut cur = x;
while parent[cur] != r {
let nxt = parent[cur];
parent[cur] = r;
cur = nxt;
}
r
}
fn union(parent: &mut [usize], a: usize, b: usize) {
let ra = find(parent, a);
let rb = find(parent, b);
if ra != rb {
parent[ra] = rb;
}
}
let mut clusters: Vec<DuplicateCluster> = vec![];
let mut next_cluster_id = 0usize;
for ((name, _side), group) in buckets {
if group.len() < 2 {
continue;
}
let n = group.len();
let mut parent: Vec<usize> = (0..n).collect();
for i in 0..n {
for j in (i + 1)..n {
let a = group[i];
let b = group[j];
if cross_only {
let av = a.video_name.trim();
let bv = b.video_name.trim();
// Empty/missing video names never count as "same" — that
// would silently block dup detection on assets where the
// labeler forgot to set the video.
if !av.is_empty() && !bv.is_empty() && av == bv {
continue;
}
}
let pa = Point::new(a.lng.unwrap(), a.lat.unwrap());
let pb = Point::new(b.lng.unwrap(), b.lat.unwrap());
if Geodesic.distance(pa, pb) > eps_m {
continue;
}
// Range × range: also require best-paired endpoints OR best
// endpoint <-> any-vertex distance to be within 2×eps. This is
// looser than before so ranges that overlap but end at slightly
// different points still cluster. Mismatched-direction pairs
// still pass via the min(same,opp) test.
if a.asset_type == "range" && b.asset_type == "range" {
let endpoints_ok = (|| {
let asa = Point::new(a.start_lng?, a.start_lat?);
let ase = Point::new(a.end_lng?, a.end_lat?);
let bsa = Point::new(b.start_lng?, b.start_lat?);
let bse = Point::new(b.end_lng?, b.end_lat?);
let same = Geodesic
.distance(asa, bsa)
.max(Geodesic.distance(ase, bse));
let opp = Geodesic
.distance(asa, bse)
.max(Geodesic.distance(ase, bsa));
Some(same.min(opp) <= eps_m * 2.0)
})()
.unwrap_or(true);
if !endpoints_ok {
continue;
}
}
union(&mut parent, i, j);
}
}
let mut by_root: HashMap<usize, Vec<usize>> = HashMap::new();
for i in 0..n {
let r = find(&mut parent, i);
by_root.entry(r).or_default().push(i);
}
for (_root, members) in by_root {
if members.len() < 2 {
continue;
}
if cross_only {
let mut videos = std::collections::HashSet::new();
for &i in &members {
videos.insert(group[i].video_name.clone());
}
if videos.len() < 2 {
continue;
}
}
let mut out_members: Vec<DuplicateMember> = vec![];
for &i in &members {
let m = group[i];
out_members.push(DuplicateMember {
id: m.id,
row_id: m.row_id.clone(),
asset_name: m.asset_name.clone(),
video_name: m.video_name.clone(),
side: m.side.clone(),
lat: m.lat,
lng: m.lng,
asset_type: m.asset_type.clone(),
start_lat: m.start_lat,
start_lng: m.start_lng,
end_lat: m.end_lat,
end_lng: m.end_lng,
});
}
clusters.push(DuplicateCluster {
id: next_cluster_id,
asset_name: name.clone(),
members: out_members,
});
next_cluster_id += 1;
}
}
clusters.sort_by(|a, b| b.members.len().cmp(&a.members.len()));
Ok(clusters)
}
#[tauri::command]
pub async fn list_video_names(
state: State<'_, AppState>,
) -> Result<Vec<String>, String> {
let rows: Vec<(String,)> = sqlx::query_as(
"SELECT DISTINCT video_name FROM assets WHERE deleted = 0 ORDER BY video_name",
)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
Ok(rows.into_iter().map(|(n,)| n).collect())
}
#[derive(Debug, Serialize)]
pub struct VideoEntry {
pub video_name: String,
pub video_number: i64,
pub completed: bool,
pub completed_at: Option<String>,
pub completed_by: Option<String>,
}
/// Sort key for stable numbering: parse the first `YYYY_MMDD_HHMMSS` pattern
/// out of the video name (timestamps captured by the on-vehicle recorder).
/// Names without a timestamp sort after timestamped ones, lexically.
fn video_sort_key(name: &str) -> String {
let bytes = name.as_bytes();
// Window-scan for the pattern: 4 digits, _, 4 digits, _, 6 digits.
let mut i = 0;
while i + 16 <= bytes.len() {
let w = &bytes[i..i + 16];
let is_digits = |b: &[u8]| b.iter().all(|c| c.is_ascii_digit());
if w[4] == b'_'
&& w[9] == b'_'
&& is_digits(&w[0..4])
&& is_digits(&w[5..9])
&& is_digits(&w[10..16])
{
// 14-char numeric key sorts chronologically.
let mut key = String::with_capacity(14 + 1 + name.len());
key.push_str(std::str::from_utf8(&w[0..4]).unwrap_or(""));
key.push_str(std::str::from_utf8(&w[5..9]).unwrap_or(""));
key.push_str(std::str::from_utf8(&w[10..16]).unwrap_or(""));
key.push('_');
key.push_str(name);
return key;
}
i += 1;
}
// No timestamp: prefix with sentinel that sorts after any real timestamp.
let mut key = String::with_capacity(15 + name.len());
key.push_str("99999999999999_");
key.push_str(name);
key
}
/// Return every distinct video name (from assets + video_metadata + the
/// status table) annotated with a deterministic 1-based number and its
/// completion state. Sort: parsed timestamp asc, then video_name asc. The
/// numbering is recomputed on every call, so adding a new video shifts later
/// numbers consistently for every client sharing the DB.
#[tauri::command]
pub async fn list_videos(
state: State<'_, AppState>,
) -> Result<Vec<VideoEntry>, String> {
let names: Vec<(String,)> = sqlx::query_as(
"SELECT DISTINCT video_name FROM (
SELECT video_name FROM assets WHERE deleted = 0
UNION
SELECT video_name FROM video_metadata
UNION
SELECT video_name FROM video_status
) WHERE TRIM(COALESCE(video_name, '')) <> ''",
)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
let mut all: Vec<String> = names.into_iter().map(|(n,)| n).collect();
all.sort_by(|a, b| video_sort_key(a).cmp(&video_sort_key(b)));
let status_rows: Vec<(String, i64, Option<String>, Option<String>)> = sqlx::query_as(
"SELECT video_name, completed, completed_at, completed_by FROM video_status",
)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
let mut status: std::collections::HashMap<
String,
(i64, Option<String>, Option<String>),
> = std::collections::HashMap::new();
for (n, c, at, by) in status_rows {
status.insert(n, (c, at, by));
}
let mut out: Vec<VideoEntry> = Vec::with_capacity(all.len());
for (idx, n) in all.into_iter().enumerate() {
let (completed, completed_at, completed_by) =
status.remove(&n).unwrap_or((0, None, None));
out.push(VideoEntry {
video_number: (idx + 1) as i64,
video_name: n,
completed: completed != 0,
completed_at,
completed_by,
});
}
Ok(out)
}
#[tauri::command]
pub async fn set_video_completed(
video_name: String,
completed: bool,
state: State<'_, AppState>,
) -> Result<(), String> {
let user = user_id();
sqlx::query(
"INSERT INTO video_status(video_name, completed, completed_at, completed_by)
VALUES (?, ?, CASE WHEN ? THEN datetime('now') ELSE NULL END, ?)
ON CONFLICT(video_name) DO UPDATE SET
completed = excluded.completed,
completed_at = CASE WHEN excluded.completed = 1 THEN datetime('now') ELSE NULL END,
completed_by = CASE WHEN excluded.completed = 1 THEN excluded.completed_by ELSE NULL END",
)
.bind(&video_name)
.bind(if completed { 1i64 } else { 0 })
.bind(if completed { 1i64 } else { 0 })
.bind(&user)
.execute(&state.pool)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn restore_assets(
ids: Vec<i64>,
state: State<'_, AppState>,
) -> Result<usize, String> {
if ids.is_empty() {
return Ok(0);
}
let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?;
let mut total_affected: u64 = 0;
for chunk in ids.chunks(500) {
let ph = vec!["?"; chunk.len()].join(",");
let upd_sql = format!(
"UPDATE assets SET deleted = 0, modified = 1, modified_by = ?, \
modified_at = datetime('now') WHERE id IN ({}) AND deleted = 1",
ph
);
let mut q = sqlx::query(&upd_sql).bind(user_id());
for id in chunk {
q = q.bind(id);
}
total_affected += q
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?
.rows_affected();
}
let ids_json = serde_json::to_string(&ids).map_err(|e| e.to_string())?;
sqlx::query(
"INSERT INTO action_history (action_type, scope, asset_ids, user_id, ts)
VALUES ('restore', 'bulk', ?, ?, datetime('now'))",
)
.bind(&ids_json)
.bind(user_id())
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
tx.commit().await.map_err(|e| e.to_string())?;
Ok(total_affected as usize)
}
#[tauri::command]
pub async fn list_asset_names(
state: State<'_, AppState>,
) -> Result<Vec<String>, String> {
let rows: Vec<(String,)> = sqlx::query_as(
"SELECT DISTINCT asset_name FROM assets WHERE deleted = 0 ORDER BY asset_name",
)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
Ok(rows.into_iter().map(|(n,)| n).collect())
}
#[tauri::command]
pub async fn get_osm_roads(state: State<'_, AppState>) -> Result<Vec<RoadOut>, String> {
let rows: Vec<(i64, Option<String>, String, i64, i64)> = sqlx::query_as(
"SELECT id, name, geojson,
COALESCE(snap_ignored, 0), COALESCE(modified, 0) FROM roads",
)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
Ok(rows
.into_iter()
.map(|(id, name, geojson, si, m)| RoadOut {
id,
name,
geojson,
snap_ignored: si != 0,
modified: m != 0,
})
.collect())
}
#[tauri::command]
pub async fn read_metadata_polyline(path: String) -> Result<Vec<[f64; 2]>, String> {
let bytes = tokio::fs::read(&path).await.map_err(|e| e.to_string())?;
let v: serde_json::Value =
serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
let track = v
.get("track")
.and_then(|t| t.as_array())
.ok_or_else(|| "missing 'track' array".to_string())?;
let mut out = vec![];
for pt in track {
let arr = match pt.as_array() {
Some(a) if a.len() >= 2 => a,
_ => continue,
};
// metadata_polyline.json stores [lat, lng]; convert to [lng, lat] for deck.gl
let lat = arr[0].as_f64().unwrap_or(f64::NAN);
let lng = arr[1].as_f64().unwrap_or(f64::NAN);
if lat.is_finite() && lng.is_finite() {
out.push([lng, lat]);
}
}
Ok(out)
}
#[tauri::command]
pub async fn import_osm_geojson(
path: String,
highway_classes: Option<Vec<String>>,
state: State<'_, AppState>,
) -> Result<usize, String> {
let bytes = tokio::fs::read(&path).await.map_err(|e| e.to_string())?;
let fc: GeoJsonFeatureCollection =
serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
let class_filter: Option<std::collections::HashSet<String>> =
highway_classes.map(|v| v.into_iter().collect());
let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?;
sqlx::query("DELETE FROM roads")
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
let mut inserted = 0usize;
for f in fc.features {
let highway = f
.properties
.get("highway")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if let Some(set) = &class_filter {
if !highway.is_empty() && !set.contains(&highway) {
continue;
}
}
let geom_type = f
.geometry
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("");
if geom_type != "LineString" {
continue;
}
let coords_v = f
.geometry
.get("coordinates")
.and_then(|v| v.as_array())
.ok_or_else(|| "missing coordinates".to_string())?;
let mut min_lat = f64::INFINITY;
let mut max_lat = f64::NEG_INFINITY;
let mut min_lng = f64::INFINITY;
let mut max_lng = f64::NEG_INFINITY;
let mut valid = false;
for c in coords_v {
let arr = match c.as_array() {
Some(a) if a.len() >= 2 => a,
_ => continue,
};
let lng = arr[0].as_f64().unwrap_or(f64::NAN);
let lat = arr[1].as_f64().unwrap_or(f64::NAN);
if !lng.is_finite() || !lat.is_finite() {
continue;
}
if lat < min_lat {
min_lat = lat;
}
if lat > max_lat {
max_lat = lat;
}
if lng < min_lng {
min_lng = lng;
}
if lng > max_lng {
max_lng = lng;
}
valid = true;
}
if !valid {
continue;
}
let name = f
.properties
.get("name")
.and_then(|v| v.as_str())
.or_else(|| f.properties.get("ref").and_then(|v| v.as_str()))
.map(|s| s.to_string());
let oneway: i64 = match f.properties.get("oneway") {
Some(serde_json::Value::Number(n)) => n.as_i64().unwrap_or(0),
Some(serde_json::Value::String(s)) => match s.as_str() {
"yes" | "true" | "1" => 1,
"-1" | "reverse" => -1,
_ => 0,
},
Some(serde_json::Value::Bool(true)) => 1,
_ => 0,
};
let geojson_str = f.geometry.to_string();
sqlx::query(
"INSERT INTO roads(name, geojson, min_lat, max_lat, min_lng, max_lng, oneway, highway)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&name)
.bind(&geojson_str)
.bind(min_lat)
.bind(max_lat)
.bind(min_lng)
.bind(max_lng)
.bind(oneway)
.bind(&highway)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
inserted += 1;
}
tx.commit().await.map_err(|e| e.to_string())?;
Ok(inserted)
}
#[derive(Debug, Serialize)]
pub struct MergeRoadsResult {
pub names_processed: usize,
pub ways_consumed: usize,
pub merged_into: usize,
pub skipped_branched: usize,
}
/// Merge OSM ways that share a `name` and connect end-to-end into a single
/// LineString. Endpoints within 1 m are treated as the same node.
/// Branched components (any node with > 2 incident ways) are skipped to keep
/// merged geometry single-stranded; user can still edit those individually.
#[tauri::command]
pub async fn merge_roads_by_name(
state: State<'_, AppState>,
) -> Result<MergeRoadsResult, String> {
use std::collections::{HashMap, HashSet};
// Pull every named road. Empty / NULL names are skipped (we can't group them).
let rows: Vec<(i64, String, String, i64, Option<String>, i64)> = sqlx::query_as(
"SELECT id, geojson, COALESCE(name, ''), COALESCE(oneway, 0),
highway, COALESCE(snap_ignored, 0)
FROM roads
WHERE name IS NOT NULL AND TRIM(name) <> ''",
)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
// Bucket by name. Carry snap_ignored alongside oneway/highway so the
// merged road can keep the dominant value (otherwise the merged row
// silently re-enables snapping for ways the user had muted).
let mut by_name: HashMap<
String,
Vec<(i64, LineString<f64>, i64, Option<String>, i64)>,
> = HashMap::new();
for (id, gj, name, ow, hw, si) in rows {
let line = match parse_linestring(&gj) {
Some(l) => l,
None => continue,
};
by_name
.entry(name)
.or_default()
.push((id, line, ow, hw, si));
}
// Endpoint keying: round to 5 decimals (~1 m). Two ways are adjacent if
// any endpoint key matches.
fn key(c: &Coord<f64>) -> (i64, i64) {
((c.x * 1e5).round() as i64, (c.y * 1e5).round() as i64)
}
let mut names_processed = 0usize;
let mut ways_consumed = 0usize;
let mut merged_into = 0usize;
let mut skipped_branched = 0usize;
let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?;
for (name, ways) in by_name {
if ways.len() < 2 {
continue;
}
names_processed += 1;
let n = ways.len();
// Map each endpoint key to the list of way indices touching it.
let mut node_to_ways: HashMap<(i64, i64), Vec<usize>> = HashMap::new();
for (i, (_, line, _, _, _)) in ways.iter().enumerate() {
let s = line.0.first().unwrap();
let e = line.0.last().unwrap();
node_to_ways.entry(key(s)).or_default().push(i);
node_to_ways.entry(key(e)).or_default().push(i);
}
// Find connected components via BFS on ways.
let mut visited = vec![false; n];
for start in 0..n {
if visited[start] {
continue;
}
let mut comp: Vec<usize> = vec![];
let mut stack = vec![start];
while let Some(u) = stack.pop() {
if visited[u] {
continue;
}
visited[u] = true;
comp.push(u);
let line = &ways[u].1;
for k in [key(line.0.first().unwrap()), key(line.0.last().unwrap())] {
if let Some(peers) = node_to_ways.get(&k) {
for &v in peers {
if !visited[v] {
stack.push(v);
}
}
}
}
}
if comp.len() < 2 {
continue;
}
// Branched check: count incident ways per endpoint, restricted to comp.
let comp_set: HashSet<usize> = comp.iter().copied().collect();
let mut deg: HashMap<(i64, i64), Vec<usize>> = HashMap::new();
for &i in &comp {
let line = &ways[i].1;
deg.entry(key(line.0.first().unwrap())).or_default().push(i);
deg.entry(key(line.0.last().unwrap())).or_default().push(i);
}
let max_deg = deg.values().map(|v| v.len()).max().unwrap_or(0);
if max_deg > 2 {
skipped_branched += 1;
continue;
}
// Find walk start: a node with degree 1 (chain endpoint) or any node (cycle).
let start_node = deg
.iter()
.find(|(_, v)| v.len() == 1)
.map(|(k, _)| *k)
.unwrap_or_else(|| *deg.keys().next().unwrap());
// Walk: from start_node, pick its way, follow to the other end, repeat.
let mut ordered_segments: Vec<Vec<Coord<f64>>> = vec![];
let mut current_node = start_node;
let mut used: HashSet<usize> = HashSet::new();
while used.len() < comp.len() {
let nexts = deg.get(&current_node).cloned().unwrap_or_default();
let pick = nexts
.into_iter()
.find(|i| !used.contains(i) && comp_set.contains(i));
let way_idx = match pick {
Some(x) => x,
None => break,
};
used.insert(way_idx);
let line = &ways[way_idx].1;
let s_key = key(line.0.first().unwrap());
let e_key = key(line.0.last().unwrap());
let coords: Vec<Coord<f64>> = if s_key == current_node {
line.0.clone()
} else {
line.0.iter().rev().copied().collect()
};
let next_node = if s_key == current_node { e_key } else { s_key };
ordered_segments.push(coords);
current_node = next_node;
}
if used.len() != comp.len() {
// Couldn't walk the whole thing — bail rather than corrupt geometry.
skipped_branched += 1;
continue;
}
// Concatenate; drop duplicate junction vertex between consecutive segments.
let mut merged: Vec<Coord<f64>> = vec![];
for (i, seg) in ordered_segments.into_iter().enumerate() {
let start = if i == 0 { 0 } else { 1 };
merged.extend(seg.into_iter().skip(start));
}
if merged.len() < 2 {
continue;
}
// Dominant oneway (most common among comp members).
let mut ow_count: HashMap<i64, usize> = HashMap::new();
for &i in &comp {
*ow_count.entry(ways[i].2).or_default() += 1;
}
let dom_ow = ow_count
.into_iter()
.max_by_key(|(_, c)| *c)
.map(|(k, _)| k)
.unwrap_or(0);
// Pick a highway value (use any present).
let dom_hw = comp
.iter()
.find_map(|&i| ways[i].3.clone())
.unwrap_or_default();
// Preserve snap_ignored: if ANY member of the cluster was muted
// for snap, the merged road inherits that — better to err on the
// side of "keep the user's mute" than to silently re-enable.
let any_ignored = comp.iter().any(|&i| ways[i].4 != 0);
// Compute bbox.
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 c in &merged {
if c.y < mn_lat { mn_lat = c.y; }
if c.y > mx_lat { mx_lat = c.y; }
if c.x < mn_lng { mn_lng = c.x; }
if c.x > mx_lng { mx_lng = c.x; }
}
// Delete the source rows.
for &i in &comp {
sqlx::query("DELETE FROM roads WHERE id = ?")
.bind(ways[i].0)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
}
// Insert merged.
let coords_array: Vec<[f64; 2]> =
merged.iter().map(|c| [c.x, c.y]).collect();
let geom = serde_json::json!({
"type": "LineString",
"coordinates": coords_array,
});
sqlx::query(
"INSERT INTO roads(name, geojson, min_lat, max_lat, min_lng, max_lng, oneway, highway, snap_ignored, modified)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)",
)
.bind(&name)
.bind(geom.to_string())
.bind(mn_lat)
.bind(mx_lat)
.bind(mn_lng)
.bind(mx_lng)
.bind(dom_ow)
.bind(&dom_hw)
.bind(if any_ignored { 1i64 } else { 0i64 })
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
ways_consumed += comp.len();
merged_into += 1;
}
}
tx.commit().await.map_err(|e| e.to_string())?;
Ok(MergeRoadsResult {
names_processed,
ways_consumed,
merged_into,
skipped_branched,
})
}
/// Merge an explicit set of road ids. Connected clusters become single
/// LineStrings; non-touching clusters stay as independent merged roads (or
/// untouched if a cluster has < 2 ways or is branched). Endpoints within
/// `snap_meters` (default 8 m) of each other are clustered so OSM ways with
/// near-but-not-touching endpoints can still merge — the resulting geometry
/// snaps both ends to the cluster centroid so there's no visible gap.
#[tauri::command]
pub async fn merge_roads_by_ids(
ids: Vec<i64>,
snap_meters: Option<f64>,
state: State<'_, AppState>,
) -> Result<MergeRoadsResult, String> {
use std::collections::{HashMap, HashSet};
if ids.is_empty() {
return Ok(MergeRoadsResult {
names_processed: 0,
ways_consumed: 0,
merged_into: 0,
skipped_branched: 0,
});
}
let snap_m = snap_meters.unwrap_or(8.0).max(0.0);
// Load the requested rows. Chunk to stay under the 999-bind ceiling.
// Tuple shape: (id, line, oneway, highway, name, snap_ignored).
let mut ways: Vec<(
i64,
LineString<f64>,
i64,
Option<String>,
Option<String>,
i64,
)> = Vec::with_capacity(ids.len());
for chunk in ids.chunks(500) {
let placeholders = vec!["?"; chunk.len()].join(",");
let sql = format!(
"SELECT id, geojson, COALESCE(oneway, 0), highway, name,
COALESCE(snap_ignored, 0)
FROM roads WHERE id IN ({})",
placeholders
);
let mut q = sqlx::query_as::<
_,
(i64, String, i64, Option<String>, Option<String>, i64),
>(&sql);
for &i in chunk {
q = q.bind(i);
}
let rows = q.fetch_all(&state.pool).await.map_err(|e| e.to_string())?;
for (id, gj, ow, hw, nm, si) in rows {
if let Some(line) = parse_linestring(&gj) {
ways.push((id, line, ow, hw, nm, si));
}
}
}
if ways.len() < 2 {
return Ok(MergeRoadsResult {
names_processed: 0,
ways_consumed: 0,
merged_into: 0,
skipped_branched: 0,
});
}
let n = ways.len();
// Endpoint clustering: greedy spatial-grid pass that joins endpoints
// within snap_m. Each endpoint either lands in an existing cluster (≤ snap_m
// from its representative) or starts a new one. After clustering, every
// way's first/last vertex is rewritten to its cluster's centroid so the
// walk produces continuous geometry without visible gaps.
let cell_deg = if snap_m > 0.0 {
(snap_m / 111_320.0).max(1e-7)
} else {
1e-7
};
fn dist_m(a: (f64, f64), b: (f64, f64)) -> f64 {
let lat0 = ((a.1 + b.1) * 0.5) * std::f64::consts::PI / 180.0;
let dx = (b.0 - a.0) * 111_320.0 * lat0.cos();
let dy = (b.1 - a.1) * 111_320.0;
(dx * dx + dy * dy).sqrt()
}
// (sum_x, sum_y, count) per cluster — running centroid so successive
// endpoints don't drift the representative away from earlier members.
let mut cluster_sum: Vec<(f64, f64, usize)> = vec![];
let mut grid: HashMap<(i64, i64), Vec<usize>> = HashMap::new();
let mut endpoint_cluster: Vec<usize> = Vec::with_capacity(n * 2);
for w in 0..n {
for end in 0..2 {
let coord = if end == 0 {
*ways[w].1 .0.first().unwrap()
} else {
*ways[w].1 .0.last().unwrap()
};
let p = (coord.x, coord.y);
let gx = (p.0 / cell_deg).floor() as i64;
let gy = (p.1 / cell_deg).floor() as i64;
let mut found: Option<usize> = None;
'search: for dy in -1..=1 {
for dx in -1..=1 {
if let Some(cell_clusters) = grid.get(&(gx + dx, gy + dy)) {
for &cid in cell_clusters {
let (sx, sy, c) = cluster_sum[cid];
let centroid = (sx / c as f64, sy / c as f64);
if dist_m(p, centroid) <= snap_m {
found = Some(cid);
break 'search;
}
}
}
}
}
let cid = if let Some(c) = found {
let (sx, sy, k) = cluster_sum[c];
cluster_sum[c] = (sx + p.0, sy + p.1, k + 1);
c
} else {
let cid = cluster_sum.len();
cluster_sum.push((p.0, p.1, 1));
grid.entry((gx, gy)).or_default().push(cid);
cid
};
endpoint_cluster.push(cid);
}
}
// Rewrite each way's first/last vertex to its cluster centroid so the
// resulting geometry is continuous after the walk.
for w in 0..n {
let s_cid = endpoint_cluster[w * 2];
let e_cid = endpoint_cluster[w * 2 + 1];
let (ssx, ssy, sc) = cluster_sum[s_cid];
let (esx, esy, ec) = cluster_sum[e_cid];
let s_rep = (ssx / sc as f64, ssy / sc as f64);
let e_rep = (esx / ec as f64, esy / ec as f64);
if let Some(first) = ways[w].1 .0.first_mut() {
first.x = s_rep.0;
first.y = s_rep.1;
}
if let Some(last) = ways[w].1 .0.last_mut() {
last.x = e_rep.0;
last.y = e_rep.1;
}
}
// After clustering every endpoint coord is exact, so the rounded `key()`
// collapses both ends of a connection to the same map key. Keeps the rest
// of the walk identical to merge_roads_by_name.
fn key(c: &Coord<f64>) -> (i64, i64) {
((c.x * 1e5).round() as i64, (c.y * 1e5).round() as i64)
}
let mut node_to_ways: HashMap<(i64, i64), Vec<usize>> = HashMap::new();
for (i, (_, line, _, _, _, _)) in ways.iter().enumerate() {
let s = line.0.first().unwrap();
let e = line.0.last().unwrap();
node_to_ways.entry(key(s)).or_default().push(i);
node_to_ways.entry(key(e)).or_default().push(i);
}
let mut ways_consumed = 0usize;
let mut merged_into = 0usize;
let mut skipped_branched = 0usize;
let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?;
let mut visited = vec![false; n];
for start in 0..n {
if visited[start] {
continue;
}
let mut comp: Vec<usize> = vec![];
let mut stack = vec![start];
while let Some(u) = stack.pop() {
if visited[u] {
continue;
}
visited[u] = true;
comp.push(u);
let line = &ways[u].1;
for k in [
key(line.0.first().unwrap()),
key(line.0.last().unwrap()),
] {
if let Some(peers) = node_to_ways.get(&k) {
for &v in peers {
if !visited[v] {
stack.push(v);
}
}
}
}
}
if comp.len() < 2 {
continue;
}
let comp_set: HashSet<usize> = comp.iter().copied().collect();
let mut deg: HashMap<(i64, i64), Vec<usize>> = HashMap::new();
for &i in &comp {
let line = &ways[i].1;
deg.entry(key(line.0.first().unwrap())).or_default().push(i);
deg.entry(key(line.0.last().unwrap())).or_default().push(i);
}
let max_deg = deg.values().map(|v| v.len()).max().unwrap_or(0);
if max_deg > 2 {
skipped_branched += 1;
continue;
}
let start_node = deg
.iter()
.find(|(_, v)| v.len() == 1)
.map(|(k, _)| *k)
.unwrap_or_else(|| *deg.keys().next().unwrap());
let mut ordered_segments: Vec<Vec<Coord<f64>>> = vec![];
let mut current_node = start_node;
let mut used: HashSet<usize> = HashSet::new();
while used.len() < comp.len() {
let nexts = deg.get(&current_node).cloned().unwrap_or_default();
let pick = nexts
.into_iter()
.find(|i| !used.contains(i) && comp_set.contains(i));
let way_idx = match pick {
Some(x) => x,
None => break,
};
used.insert(way_idx);
let line = &ways[way_idx].1;
let s_key = key(line.0.first().unwrap());
let e_key = key(line.0.last().unwrap());
let coords: Vec<Coord<f64>> = if s_key == current_node {
line.0.clone()
} else {
line.0.iter().rev().copied().collect()
};
let next_node = if s_key == current_node { e_key } else { s_key };
ordered_segments.push(coords);
current_node = next_node;
}
if used.len() != comp.len() {
skipped_branched += 1;
continue;
}
let mut merged: Vec<Coord<f64>> = vec![];
for (i, seg) in ordered_segments.into_iter().enumerate() {
let start = if i == 0 { 0 } else { 1 };
merged.extend(seg.into_iter().skip(start));
}
if merged.len() < 2 {
continue;
}
// Pick the most common non-empty name across the cluster; fall back
// to "" if none has one.
let mut name_count: HashMap<String, usize> = HashMap::new();
for &i in &comp {
if let Some(n) = ways[i].4.as_ref() {
let t = n.trim();
if !t.is_empty() {
*name_count.entry(t.to_string()).or_default() += 1;
}
}
}
let dom_name = name_count
.into_iter()
.max_by_key(|(_, c)| *c)
.map(|(k, _)| k)
.unwrap_or_default();
let mut ow_count: HashMap<i64, usize> = HashMap::new();
for &i in &comp {
*ow_count.entry(ways[i].2).or_default() += 1;
}
let dom_ow = ow_count
.into_iter()
.max_by_key(|(_, c)| *c)
.map(|(k, _)| k)
.unwrap_or(0);
let dom_hw = comp
.iter()
.find_map(|&i| ways[i].3.clone())
.unwrap_or_default();
// Preserve snap_ignored: if any cluster member was muted, the merged
// road inherits the mute. Avoids silently re-enabling snap on roads
// the user had explicitly excluded.
let any_ignored = comp.iter().any(|&i| ways[i].5 != 0);
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 c in &merged {
if c.y < mn_lat { mn_lat = c.y; }
if c.y > mx_lat { mx_lat = c.y; }
if c.x < mn_lng { mn_lng = c.x; }
if c.x > mx_lng { mx_lng = c.x; }
}
for &i in &comp {
sqlx::query("DELETE FROM roads WHERE id = ?")
.bind(ways[i].0)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
}
let coords_array: Vec<[f64; 2]> = merged.iter().map(|c| [c.x, c.y]).collect();
let geom = serde_json::json!({
"type": "LineString",
"coordinates": coords_array,
});
sqlx::query(
"INSERT INTO roads(name, geojson, min_lat, max_lat, min_lng, max_lng, oneway, highway, snap_ignored, modified)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)",
)
.bind(if dom_name.is_empty() { None } else { Some(dom_name) })
.bind(geom.to_string())
.bind(mn_lat)
.bind(mx_lat)
.bind(mn_lng)
.bind(mx_lng)
.bind(dom_ow)
.bind(if dom_hw.is_empty() { None } else { Some(dom_hw) })
.bind(if any_ignored { 1i64 } else { 0i64 })
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
ways_consumed += comp.len();
merged_into += 1;
}
tx.commit().await.map_err(|e| e.to_string())?;
Ok(MergeRoadsResult {
names_processed: 0,
ways_consumed,
merged_into,
skipped_branched,
})
}
#[tauri::command]
pub async fn export_osm_roads(
path: String,
state: State<'_, AppState>,
) -> Result<usize, String> {
let rows: Vec<(i64, Option<String>, String, i64, Option<String>)> = sqlx::query_as(
"SELECT id, name, geojson, COALESCE(oneway, 0), highway FROM roads",
)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
let mut features: Vec<serde_json::Value> = Vec::with_capacity(rows.len());
for (id, name, gj, ow, hw) in &rows {
let geom: serde_json::Value = match serde_json::from_str(gj) {
Ok(v) => v,
Err(_) => continue,
};
features.push(serde_json::json!({
"type": "Feature",
"geometry": geom,
"properties": {
"id": id,
"name": name,
"oneway": ow,
"highway": hw,
},
}));
}
let fc = serde_json::json!({
"type": "FeatureCollection",
"features": features,
});
tokio::fs::write(&path, serde_json::to_string_pretty(&fc).map_err(|e| e.to_string())?)
.await
.map_err(|e| e.to_string())?;
Ok(features.len())
}
#[derive(Debug, Serialize)]
pub struct BboxOut {
pub min_lat: f64,
pub max_lat: f64,
pub min_lng: f64,
pub max_lng: f64,
}
// Same Overpass road-class set used by the bbox-based generator. Kept as a
// constant so both code paths stay in sync.
const OSM_HIGHWAY_CLASSES: &str =
"motorway|trunk|primary|secondary|tertiary|motorway_link|trunk_link|primary_link|secondary_link|tertiary_link|residential|unclassified|service";
#[derive(Debug, Serialize)]
pub struct GenerateOsmResult {
pub ways_kept: usize,
pub source: String,
pub points_used: usize,
pub videos_used: usize,
}
#[tauri::command]
pub async fn generate_osm_near_assets(
min_lat: f64,
min_lng: f64,
max_lat: f64,
max_lng: f64,
keep_threshold_m: Option<f64>,
path: String,
overpass_url: Option<String>,
state: State<'_, AppState>,
) -> Result<GenerateOsmResult, String> {
// New flow: Overpass query is the visible bbox (gives every way in view,
// including highways that earlier around: + min_votes=2 dropped). The
// returned ways are then filtered against a unified reference set built
// from per-video metadata polyline points + live asset coords. A way is
// kept if any of its segments comes within `keep_threshold_m` of any
// reference point. This covers highways with sparse assets via the
// metadata polyline (the GPS track always touches the highway it drove)
// and urban side streets via the dense asset cloud.
let threshold_m = keep_threshold_m.unwrap_or(50.0).max(1.0);
if !(min_lat < max_lat) || !(min_lng < max_lng) {
return Err("invalid bbox: min must be < max for both lat and lng".into());
}
// --- Build the reference-point set (lng, lat). ---
// Pull asset centres for the post-filter votes.
let asset_coords: Vec<(f64, f64)> = sqlx::query_as::<_, (f64, f64)>(
"SELECT lng, lat FROM assets
WHERE deleted = 0 AND lat IS NOT NULL AND lng IS NOT NULL",
)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
// Pull every metadata polyline point (DB-persisted, stored as
// [[lng, lat], ...] to match deck.gl convention).
let metadata_rows: Vec<(String,)> = sqlx::query_as(
"SELECT track_json FROM video_metadata",
)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
let mut ref_points: Vec<(f64, f64)> = Vec::new();
let mut videos_used = 0usize;
for (json,) in &metadata_rows {
let track: Vec<[f64; 2]> = match serde_json::from_str(json) {
Ok(t) => t,
Err(_) => continue,
};
if track.is_empty() {
continue;
}
videos_used += 1;
for p in track {
ref_points.push((p[0], p[1]));
}
}
for c in &asset_coords {
ref_points.push(*c);
}
if ref_points.is_empty() {
return Err(
"nothing to filter against. Import per-video metadata or assets first \
so the generator knows which Overpass-returned roads to keep."
.into(),
);
}
let source = if videos_used > 0 && !asset_coords.is_empty() {
"metadata+assets"
} else if videos_used > 0 {
"metadata"
} else {
"assets"
};
// --- Overpass: bbox query, no around: list. ---
let query = format!(
"[out:json][timeout:180];\
(way[\"highway\"~\"{classes}\"]({mn_lat},{mn_lng},{mx_lat},{mx_lng}););\
out body geom;",
classes = OSM_HIGHWAY_CLASSES,
mn_lat = min_lat,
mn_lng = min_lng,
mx_lat = max_lat,
mx_lng = max_lng,
);
let url = overpass_url
.unwrap_or_else(|| "https://overpass-api.de/api/interpreter".to_string());
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(240))
.user_agent(concat!(
"kml_map_tool/",
env!("CARGO_PKG_VERSION"),
" (+https://overpass-api.de/api/interpreter)"
))
.build()
.map_err(|e| e.to_string())?;
let resp = client
.post(&url)
.header("Accept", "application/json")
.form(&[("data", &query)])
.send()
.await
.map_err(|e| format!("Overpass request failed: {}", e))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
let snippet: String = body.chars().take(400).collect();
return Err(format!("Overpass HTTP {}{}", status, snippet));
}
let body: serde_json::Value =
resp.json().await.map_err(|e| format!("Overpass parse: {}", e))?;
let elements = body
.get("elements")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let mut features: Vec<serde_json::Value> = vec![];
for el in elements {
if el.get("type").and_then(|v| v.as_str()) != Some("way") {
continue;
}
let geom = match el.get("geometry").and_then(|v| v.as_array()) {
Some(g) if g.len() >= 2 => g.clone(),
_ => continue,
};
let coords_v: Vec<[f64; 2]> = geom
.iter()
.filter_map(|p| {
let lon = p.get("lon")?.as_f64()?;
let lat = p.get("lat")?.as_f64()?;
Some([lon, lat])
})
.collect();
if coords_v.len() < 2 {
continue;
}
let tags = el.get("tags").cloned().unwrap_or(serde_json::json!({}));
let id = el.get("id").cloned().unwrap_or(serde_json::json!(null));
let name = tags.get("name").cloned();
let highway = tags.get("highway").cloned();
let oneway = tags.get("oneway").and_then(|v| v.as_str()).map(|s| {
match s {
"yes" | "true" | "1" => 1i64,
"-1" | "reverse" => -1,
_ => 0,
}
});
let mut props = serde_json::Map::new();
props.insert("id".into(), id);
if let Some(n) = name {
props.insert("name".into(), n);
}
if let Some(h) = highway {
props.insert("highway".into(), h);
}
if let Some(o) = oneway {
props.insert("oneway".into(), serde_json::json!(o));
}
features.push(serde_json::json!({
"type": "Feature",
"geometry": { "type": "LineString", "coordinates": coords_v },
"properties": props,
}));
}
// Post-filter: keep a way if any of its segments comes within
// `threshold_m` of any reference point (metadata polyline points or
// asset coords). Permissive — needs only ONE hit, vs the old
// min_votes=2 which dropped sparse-asset highways.
let kept = filter_features_by_reference_points(
features,
&ref_points,
threshold_m,
);
let fc = serde_json::json!({
"type": "FeatureCollection",
"features": kept,
});
let count = kept_count(&fc);
tokio::fs::write(&path, serde_json::to_string_pretty(&fc).map_err(|e| e.to_string())?)
.await
.map_err(|e| e.to_string())?;
Ok(GenerateOsmResult {
ways_kept: count,
source: source.to_string(),
// Reuse points_used to surface the size of the reference set the
// post-filter checked against (asset coords + every metadata vertex).
points_used: ref_points.len(),
videos_used,
})
}
fn kept_count(fc: &serde_json::Value) -> usize {
fc.get("features")
.and_then(|f| f.as_array())
.map(|a| a.len())
.unwrap_or(0)
}
// Squared-Euclidean point-to-segment distance in metres, using a local
// equirectangular projection at the segment midpoint. Accurate to within
// well under a metre at typical road-segment scales — fine for 30 m
// thresholding.
fn point_segment_distance_m(
p_lng: f64, p_lat: f64,
a_lng: f64, a_lat: f64,
b_lng: f64, b_lat: f64,
) -> f64 {
let lat0 = ((a_lat + b_lat) * 0.5).to_radians();
let m_per_lng = 111_320.0 * lat0.cos();
let m_per_lat = 111_320.0;
let ax = a_lng * m_per_lng;
let ay = a_lat * m_per_lat;
let bx = b_lng * m_per_lng;
let by = b_lat * m_per_lat;
let px = p_lng * m_per_lng;
let py = p_lat * m_per_lat;
let dx = bx - ax;
let dy = by - ay;
let len2 = dx * dx + dy * dy;
let (cx, cy) = if len2 < 1e-9 {
(ax, ay)
} else {
let t = (((px - ax) * dx + (py - ay) * dy) / len2).clamp(0.0, 1.0);
(ax + t * dx, ay + t * dy)
};
let ex = px - cx;
let ey = py - cy;
(ex * ex + ey * ey).sqrt()
}
/// Permissive filter: keep a way if any of its segments comes within
/// `threshold_m` of any reference point. Reference points are passed as
/// `(lng, lat)`. Single hit is enough — designed for the bbox+filter flow
/// where we want every road touched by either metadata polylines or assets,
/// including highways with sparse asset coverage.
///
/// Builds a spatial-hash grid keyed by `cell_deg = threshold_m / 111320`,
/// then for each way walks its bbox cell + a 1-ring neighbourhood; at most
/// one exact distance check per nearby reference point. ~O(W × P_local).
fn filter_features_by_reference_points(
features: Vec<serde_json::Value>,
ref_points: &[(f64, f64)],
threshold_m: f64,
) -> Vec<serde_json::Value> {
use std::collections::HashMap;
let cell_deg = (threshold_m / 111_320.0).max(1e-7);
let mut grid: HashMap<(i64, i64), Vec<usize>> = HashMap::new();
for (i, (lng, lat)) in ref_points.iter().enumerate() {
let gx = (lng / cell_deg).floor() as i64;
let gy = (lat / cell_deg).floor() as i64;
grid.entry((gx, gy)).or_default().push(i);
}
let mut out: Vec<serde_json::Value> = Vec::new();
for feat in features {
let pts: Vec<[f64; 2]> = feat
.get("geometry")
.and_then(|g| g.get("coordinates"))
.and_then(|c| c.as_array())
.map(|arr| {
arr.iter()
.filter_map(|p| {
let a = p.as_array()?;
let lng = a.first()?.as_f64()?;
let lat = a.get(1)?.as_f64()?;
Some([lng, lat])
})
.collect()
})
.unwrap_or_default();
if pts.len() < 2 {
continue;
}
let mut keep = false;
// Walk segments at sub-cell intervals so a long segment crossing a
// populated cell can't slip through the grid.
let step_deg = cell_deg * 0.5;
'segs: for w in pts.windows(2) {
let a = w[0];
let b = w[1];
let dx = b[0] - a[0];
let dy = b[1] - a[1];
let len = (dx * dx + dy * dy).sqrt();
let steps = ((len / step_deg).ceil() as usize).max(1);
for s in 0..=steps {
let t = s as f64 / steps as f64;
let lng = a[0] + dx * t;
let lat = a[1] + dy * t;
let gx = (lng / cell_deg).floor() as i64;
let gy = (lat / cell_deg).floor() as i64;
for dy_c in -1..=1 {
for dx_c in -1..=1 {
if let Some(idxs) = grid.get(&(gx + dx_c, gy + dy_c)) {
for &idx in idxs {
let p = ref_points[idx];
// Cheap equirectangular distance — accurate
// enough for 50 m thresholding.
let lat0 = ((lat + p.1) * 0.5).to_radians();
let m_per_lng = (111_320.0_f64 * lat0.cos()).max(1.0);
let mx = (p.0 - lng) * m_per_lng;
let my = (p.1 - lat) * 111_320.0;
let d = (mx * mx + my * my).sqrt();
if d <= threshold_m {
keep = true;
break 'segs;
}
}
}
}
}
}
}
if keep {
out.push(feat);
}
}
out
}
#[tauri::command]
pub async fn assets_bbox(
state: State<'_, AppState>,
) -> Result<Option<BboxOut>, String> {
// SELECT MIN/MAX over the per-row bbox columns gives the union bbox of
// all live (non-deleted) assets in one indexed scan. Returns None if
// the assets table is empty so the caller can fall back to the
// visible viewport.
let row: Option<(
Option<f64>,
Option<f64>,
Option<f64>,
Option<f64>,
)> = sqlx::query_as(
"SELECT MIN(min_lat), MAX(max_lat), MIN(min_lng), MAX(max_lng)
FROM assets WHERE deleted = 0",
)
.fetch_optional(&state.pool)
.await
.map_err(|e| e.to_string())?;
Ok(match row {
Some((Some(mn_lat), Some(mx_lat), Some(mn_lng), Some(mx_lng))) => {
Some(BboxOut {
min_lat: mn_lat,
max_lat: mx_lat,
min_lng: mn_lng,
max_lng: mx_lng,
})
}
_ => None,
})
}
#[tauri::command]
pub async fn generate_osm_for_bbox(
min_lat: f64,
min_lng: f64,
max_lat: f64,
max_lng: f64,
path: String,
overpass_url: Option<String>,
) -> Result<usize, String> {
let url = overpass_url
.unwrap_or_else(|| "https://overpass-api.de/api/interpreter".to_string());
let query = format!(
"[out:json][timeout:180];\
(way[\"highway\"~\"motorway|trunk|primary|secondary|tertiary|motorway_link|trunk_link|primary_link|secondary_link|tertiary_link|residential|unclassified|service\"]\
({},{},{},{}););\
out body geom;",
min_lat, min_lng, max_lat, max_lng
);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(240))
// Overpass mirrors require an identifying User-Agent and reject
// anonymous traffic with HTTP 406. They also content-negotiate, so
// we need an Accept header that matches what they produce.
.user_agent(concat!(
"kml_map_tool/",
env!("CARGO_PKG_VERSION"),
" (+https://overpass-api.de/api/interpreter)"
))
.build()
.map_err(|e| e.to_string())?;
// Send the query as application/x-www-form-urlencoded with `data=<query>`.
// Some Overpass mirrors return 406 Not Acceptable for raw bodies with no
// Content-Type — the form encoding is the documented, stable shape.
let resp = client
.post(&url)
.header("Accept", "application/json")
.form(&[("data", &query)])
.send()
.await
.map_err(|e| format!("Overpass request failed: {}", e))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
// Trim huge HTML error pages that some mirrors return.
let snippet: String = body.chars().take(400).collect();
return Err(format!("Overpass HTTP {}{}", status, snippet));
}
let body: serde_json::Value =
resp.json().await.map_err(|e| format!("Overpass parse: {}", e))?;
let elements = body
.get("elements")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let mut features: Vec<serde_json::Value> = vec![];
for el in elements {
if el.get("type").and_then(|v| v.as_str()) != Some("way") {
continue;
}
let geom = match el.get("geometry").and_then(|v| v.as_array()) {
Some(g) if g.len() >= 2 => g.clone(),
_ => continue,
};
let coords: Vec<[f64; 2]> = geom
.iter()
.filter_map(|p| {
let lon = p.get("lon")?.as_f64()?;
let lat = p.get("lat")?.as_f64()?;
Some([lon, lat])
})
.collect();
if coords.len() < 2 {
continue;
}
let tags = el.get("tags").cloned().unwrap_or(serde_json::Value::Null);
let name = tags
.get("name")
.and_then(|v| v.as_str())
.or_else(|| tags.get("ref").and_then(|v| v.as_str()))
.map(|s| s.to_string());
let highway = tags
.get("highway")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let oneway: i64 = match tags.get("oneway").and_then(|v| v.as_str()) {
Some("yes") | Some("true") | Some("1") => 1,
Some("-1") | Some("reverse") => -1,
_ => {
if tags.get("junction").and_then(|v| v.as_str()) == Some("roundabout")
|| matches!(
tags.get("highway").and_then(|v| v.as_str()),
Some("motorway") | Some("motorway_link")
)
{
1
} else {
0
}
}
};
features.push(serde_json::json!({
"type": "Feature",
"geometry": {"type": "LineString", "coordinates": coords},
"properties": {
"id": el.get("id"),
"name": name,
"highway": highway,
"oneway": oneway,
},
}));
}
let fc = serde_json::json!({
"type": "FeatureCollection",
"features": features,
});
tokio::fs::write(&path, serde_json::to_string(&fc).map_err(|e| e.to_string())?)
.await
.map_err(|e| e.to_string())?;
Ok(features.len())
}
#[tauri::command]
pub async fn set_road_snap_ignored(
id: i64,
ignored: bool,
state: State<'_, AppState>,
) -> Result<bool, String> {
sqlx::query("UPDATE roads SET snap_ignored = ? WHERE id = ?")
.bind(if ignored { 1 } else { 0 })
.bind(id)
.execute(&state.pool)
.await
.map_err(|e| e.to_string())?;
Ok(ignored)
}
#[tauri::command]
pub async fn flip_road_direction(
id: i64,
state: State<'_, AppState>,
) -> Result<i64, String> {
let row: (i64,) = sqlx::query_as("SELECT COALESCE(oneway, 0) FROM roads WHERE id = ?")
.bind(id)
.fetch_one(&state.pool)
.await
.map_err(|e| e.to_string())?;
// 0 → 1 (set forward); 1 → -1; -1 → 1.
let next = match row.0 {
0 => 1,
v if v > 0 => -1,
_ => 1,
};
sqlx::query("UPDATE roads SET oneway = ?, modified = 1 WHERE id = ?")
.bind(next)
.bind(id)
.execute(&state.pool)
.await
.map_err(|e| e.to_string())?;
Ok(next)
}
#[tauri::command]
pub async fn delete_roads_by_highway(
classes: Vec<String>,
state: State<'_, AppState>,
) -> Result<usize, String> {
if classes.is_empty() {
return Ok(0);
}
let placeholders = vec!["?"; classes.len()].join(",");
let sql = format!(
"DELETE FROM roads WHERE highway IN ({})",
placeholders
);
let mut q = sqlx::query(&sql);
for c in &classes {
q = q.bind(c);
}
let res = q.execute(&state.pool).await.map_err(|e| e.to_string())?;
Ok(res.rows_affected() as usize)
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct RoadClassCount {
pub highway: Option<String>,
pub n: i64,
}
#[tauri::command]
pub async fn list_road_classes(
state: State<'_, AppState>,
) -> Result<Vec<RoadClassCount>, String> {
sqlx::query_as::<_, RoadClassCount>(
"SELECT highway, COUNT(*) AS n FROM roads GROUP BY highway ORDER BY n DESC",
)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn create_osm_road(
name: Option<String>,
coords: Vec<[f64; 2]>,
state: State<'_, AppState>,
) -> Result<i64, String> {
if coords.len() < 2 {
return Err("a road must have at least 2 vertices".into());
}
let mut min_lat = f64::INFINITY;
let mut max_lat = f64::NEG_INFINITY;
let mut min_lng = f64::INFINITY;
let mut max_lng = f64::NEG_INFINITY;
for c in &coords {
let lng = c[0];
let lat = c[1];
if !lng.is_finite() || !lat.is_finite() {
return Err("non-finite coordinate".into());
}
if lat < min_lat {
min_lat = lat;
}
if lat > max_lat {
max_lat = lat;
}
if lng < min_lng {
min_lng = lng;
}
if lng > max_lng {
max_lng = lng;
}
}
let geom = serde_json::json!({
"type": "LineString",
"coordinates": coords,
});
let row: (i64,) = sqlx::query_as(
"INSERT INTO roads(name, geojson, min_lat, max_lat, min_lng, max_lng, modified)
VALUES (?, ?, ?, ?, ?, ?, 1) RETURNING id",
)
.bind(&name)
.bind(geom.to_string())
.bind(min_lat)
.bind(max_lat)
.bind(min_lng)
.bind(max_lng)
.fetch_one(&state.pool)
.await
.map_err(|e| e.to_string())?;
Ok(row.0)
}
#[tauri::command]
pub async fn delete_osm_road(
id: i64,
state: State<'_, AppState>,
) -> Result<(), String> {
sqlx::query("DELETE FROM roads WHERE id = ?")
.bind(id)
.execute(&state.pool)
.await
.map_err(|e| e.to_string())?;
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)
}
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 = ?, modified = 1
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, modified)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1) 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>,
// Snapshots of every road that was mutated (fully deleted or partially
// clipped). The OSM-edit undo replays these via restore_road_state, which
// re-inserts rows whose ids no longer exist.
pub before_snapshots: Vec<RoadSnapshot>,
}
/// 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, i64)> =
sqlx::query_as(
"SELECT r.id, r.name, r.geojson, COALESCE(r.oneway, 0), r.highway,
COALESCE(r.snap_ignored, 0), COALESCE(r.modified, 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 mut before_snapshots: Vec<RoadSnapshot> = Vec::new();
let examined = candidate_rows.len();
for (id, name, geojson, oneway, highway, snap_ignored, modified) 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.
before_snapshots.push(RoadSnapshot {
id,
name: name.clone(),
geojson: geojson.clone(),
oneway,
highway: highway.clone(),
snap_ignored,
modified,
});
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: snapshot, delete
// the original, and re-insert the surviving outside runs.
before_snapshots.push(RoadSnapshot {
id,
name: name.clone(),
geojson: geojson.clone(),
oneway,
highway: highway.clone(),
snap_ignored,
modified,
});
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, modified)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1) 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,
before_snapshots,
})
}
/// 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))
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RoadSnapshot {
pub id: i64,
pub name: Option<String>,
pub geojson: String,
pub oneway: i64,
pub highway: Option<String>,
pub snap_ignored: i64,
// Snapshot the modified flag too — undoing a mutation should also revert
// the green-highlight indicator to its pre-mutation state.
#[serde(default)]
pub modified: i64,
}
/// Capture the full state of a small set of roads so the frontend can revert
/// edits via `restore_road_state`. Used by the in-session OSM edit undo
/// (3-step ring buffer); not part of the global audit log.
#[tauri::command]
pub async fn osm_road_snapshot(
ids: Vec<i64>,
state: State<'_, AppState>,
) -> Result<Vec<RoadSnapshot>, String> {
if ids.is_empty() {
return Ok(vec![]);
}
let mut out: Vec<RoadSnapshot> = Vec::with_capacity(ids.len());
for chunk in ids.chunks(500) {
let placeholders = vec!["?"; chunk.len()].join(",");
let sql = format!(
"SELECT id, name, geojson, COALESCE(oneway, 0), highway,
COALESCE(snap_ignored, 0), COALESCE(modified, 0)
FROM roads WHERE id IN ({})",
placeholders
);
let mut q = sqlx::query_as::<
_,
(i64, Option<String>, String, i64, Option<String>, i64, i64),
>(&sql);
for &i in chunk {
q = q.bind(i);
}
let rows = q.fetch_all(&state.pool).await.map_err(|e| e.to_string())?;
for (id, name, geojson, oneway, highway, snap_ignored, modified) in rows {
out.push(RoadSnapshot {
id,
name,
geojson,
oneway,
highway,
snap_ignored,
modified,
});
}
}
Ok(out)
}
/// Replay a list of snapshots back into `roads`. Upserts by id: existing rows
/// are updated in place; deleted rows are re-inserted with their original id
/// (lasso delete uses this path). The R-tree trigger handles inserts; updates
/// are mirrored manually because there's no AFTER UPDATE trigger.
#[tauri::command]
pub async fn restore_road_state(
snapshots: Vec<RoadSnapshot>,
state: State<'_, AppState>,
) -> Result<usize, String> {
if snapshots.is_empty() {
return Ok(0);
}
let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?;
let mut applied = 0usize;
for s in snapshots {
let line = match parse_linestring(&s.geojson) {
Some(l) => l,
None => continue,
};
let (mut mn_lat, mut mx_lat) = (f64::INFINITY, f64::NEG_INFINITY);
let (mut mn_lng, mut mx_lng) = (f64::INFINITY, f64::NEG_INFINITY);
for c in &line.0 {
if c.y < mn_lat { mn_lat = c.y; }
if c.y > mx_lat { mx_lat = c.y; }
if c.x < mn_lng { mn_lng = c.x; }
if c.x > mx_lng { mx_lng = c.x; }
}
let res = sqlx::query(
"UPDATE roads
SET name = ?, geojson = ?,
min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?,
oneway = ?, highway = ?, snap_ignored = ?, modified = ?
WHERE id = ?",
)
.bind(&s.name)
.bind(&s.geojson)
.bind(mn_lat)
.bind(mx_lat)
.bind(mn_lng)
.bind(mx_lng)
.bind(s.oneway)
.bind(&s.highway)
.bind(s.snap_ignored)
.bind(s.modified)
.bind(s.id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
if res.rows_affected() > 0 {
// Keep R-tree in sync — no UPDATE trigger on roads, so emulate
// via delete+insert (matches update_osm_road).
sqlx::query("DELETE FROM roads_rtree WHERE id = ?")
.bind(s.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(s.id)
.bind(mn_lat)
.bind(mx_lat)
.bind(mn_lng)
.bind(mx_lng)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
applied += 1;
} else {
// Row no longer exists — re-insert with the original id. The
// AFTER INSERT trigger on roads will populate roads_rtree.
sqlx::query(
"INSERT INTO roads(id, name, geojson, min_lat, max_lat,
min_lng, max_lng, oneway, highway, snap_ignored, modified)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(s.id)
.bind(&s.name)
.bind(&s.geojson)
.bind(mn_lat)
.bind(mx_lat)
.bind(mn_lng)
.bind(mx_lng)
.bind(s.oneway)
.bind(&s.highway)
.bind(s.snap_ignored)
.bind(s.modified)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
applied += 1;
}
}
tx.commit().await.map_err(|e| e.to_string())?;
Ok(applied)
}
#[tauri::command]
pub async fn update_osm_road(
id: i64,
coords: Vec<[f64; 2]>,
state: State<'_, AppState>,
) -> Result<(), String> {
if coords.len() < 2 {
return Err("a road must have at least 2 vertices".into());
}
let mut min_lat = f64::INFINITY;
let mut max_lat = f64::NEG_INFINITY;
let mut min_lng = f64::INFINITY;
let mut max_lng = f64::NEG_INFINITY;
for c in &coords {
let lng = c[0];
let lat = c[1];
if !lng.is_finite() || !lat.is_finite() {
return Err("non-finite coordinate".into());
}
if lat < min_lat {
min_lat = lat;
}
if lat > max_lat {
max_lat = lat;
}
if lng < min_lng {
min_lng = lng;
}
if lng > max_lng {
max_lng = lng;
}
}
let geom = serde_json::json!({
"type": "LineString",
"coordinates": coords,
});
sqlx::query(
"UPDATE roads SET geojson = ?, min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?, modified = 1 WHERE id = ?",
)
.bind(geom.to_string())
.bind(min_lat)
.bind(max_lat)
.bind(min_lng)
.bind(max_lng)
.bind(id)
.execute(&state.pool)
.await
.map_err(|e| e.to_string())?;
// Refresh R-tree row (no UPDATE trigger; emulate via delete+insert).
sqlx::query("DELETE FROM roads_rtree WHERE id = ?")
.bind(id)
.execute(&state.pool)
.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(min_lat)
.bind(max_lat)
.bind(min_lng)
.bind(max_lng)
.execute(&state.pool)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
fn parse_linestring(geojson: &str) -> Option<LineString<f64>> {
let v: serde_json::Value = serde_json::from_str(geojson).ok()?;
let coords = v.get("coordinates")?.as_array()?;
let mut out = Vec::with_capacity(coords.len());
for c in coords {
let arr = c.as_array()?;
if arr.len() < 2 {
return None;
}
out.push(Coord {
x: arr[0].as_f64()?,
y: arr[1].as_f64()?,
});
}
if out.len() < 2 {
return None;
}
Some(LineString(out))
}
/// Spatial grid over flattened road segments for the bulk snap path. Each
/// segment is bucketed into every cell its bbox intersects; the per-asset
/// query gathers segment indices from the cell containing the asset plus a
/// 1-ring neighbourhood, so any segment within `max_d` of the asset is
/// guaranteed to be in the candidate set. Cuts bulk snap from
/// O(assets × all_segments) to O(assets × segments_per_cell).
struct SnapIndex {
segments: Vec<(Coord<f64>, Coord<f64>, i32)>,
grid: std::collections::HashMap<(i64, i64), Vec<usize>>,
cell_deg: f64,
}
impl SnapIndex {
fn build(roads: &[(LineString<f64>, i32)], max_d: f64) -> Self {
// cell ≈ max_d in latitude — the 3×3 query then spans 3·max_d, which
// comfortably covers any segment within max_d of the asset.
let cell_deg = (max_d / 111_320.0).max(1e-7);
let mut segments: Vec<(Coord<f64>, Coord<f64>, i32)> = Vec::new();
let mut grid: std::collections::HashMap<(i64, i64), Vec<usize>> =
std::collections::HashMap::new();
for (line, ow) in roads {
for seg in line.lines() {
let idx = segments.len();
segments.push((seg.start, seg.end, *ow));
let mn_lat = seg.start.y.min(seg.end.y);
let mx_lat = seg.start.y.max(seg.end.y);
let mn_lng = seg.start.x.min(seg.end.x);
let mx_lng = seg.start.x.max(seg.end.x);
let gy0 = (mn_lat / cell_deg).floor() as i64;
let gy1 = (mx_lat / cell_deg).floor() as i64;
let gx0 = (mn_lng / cell_deg).floor() as i64;
let gx1 = (mx_lng / cell_deg).floor() as i64;
for gy in gy0..=gy1 {
for gx in gx0..=gx1 {
grid.entry((gx, gy)).or_default().push(idx);
}
}
}
}
Self {
segments,
grid,
cell_deg,
}
}
fn candidates(
&self,
lng: f64,
lat: f64,
) -> Vec<(Coord<f64>, Coord<f64>, i32)> {
let gx = (lng / self.cell_deg).floor() as i64;
let gy = (lat / self.cell_deg).floor() as i64;
// Cell is `cell_deg` wide in BOTH lat and lng degrees, but lng-degrees
// shrink in metres as |lat| → 90. The ring in lat is fine at 1
// (cell_deg ≈ max_d in metres). For lng, span one cell in metres is
// `cell_deg × 111_320 × cos(lat)` = `max_d × cos(lat)`, so we need
// ring_lng cells such that `ring_lng × max_d × cos(lat) ≥ max_d`,
// i.e. ring_lng ≥ 1/cos(lat). Floor at 1, clamp at a sane upper to
// avoid pathological lookups very close to the poles.
let cos_l = (lat * std::f64::consts::PI / 180.0)
.cos()
.abs()
.max(0.05);
let ring_lng = (1.0 / cos_l).ceil() as i64;
let ring_lng = ring_lng.max(1).min(20);
let ring_lat: i64 = 1;
let mut idxs: Vec<usize> = Vec::new();
for dy in -ring_lat..=ring_lat {
for dx in -ring_lng..=ring_lng {
if let Some(v) = self.grid.get(&(gx + dx, gy + dy)) {
idxs.extend(v.iter().copied());
}
}
}
idxs.sort_unstable();
idxs.dedup();
idxs.into_iter().map(|i| self.segments[i]).collect()
}
}
/// Snap a single (lng, lat) to the closest segment in `candidates`, then
/// apply a side-aware perpendicular offset. Returns Ok((new_lat, new_lng))
/// when the closest road is within `max_d`. The sign convention:
/// side="Left"/"LHS"/"L" → +perpendicular relative to vehicle heading
/// side="Right"/"RHS"/"R" → perpendicular
/// otherwise → fall back to position-derived sign
fn snap_point_with_offset(
candidates: &[(LineString<f64>, i32)],
side: Option<&str>,
metadata_track: Option<&[[f64; 2]]>,
lng: f64,
lat: f64,
offset: f64,
max_d: f64,
) -> Result<(f64, f64), String> {
let segs = candidates.iter().flat_map(|(l, ow)| {
l.lines().map(move |s| (s.start, s.end, *ow))
});
snap_point_with_segments(segs, side, metadata_track, lng, lat, offset, max_d)
}
/// Generic segment-iterating core used by both `snap_point_with_offset`
/// (single asset, R-tree pre-filtered) and the bulk snap path with a
/// `SnapIndex` that pre-filters segments via a spatial grid. Avoids the
/// O(assets × roads × segments) blow-up of the bulk snap by giving each
/// asset only the segments in its 3×3 grid neighbourhood.
fn snap_point_with_segments<I>(
segs: I,
side: Option<&str>,
metadata_track: Option<&[[f64; 2]]>,
lng: f64,
lat: f64,
offset: f64,
max_d: f64,
) -> Result<(f64, f64), String>
where
I: IntoIterator<Item = (Coord<f64>, Coord<f64>, i32)>,
{
let pt = Point::new(lng, lat);
let mut best: Option<(f64, Point<f64>, (f64, f64), (f64, f64), i32)> = None;
for (start, end, ow) in segs {
let seg_ls = LineString(vec![start, end]);
let snapped = match seg_ls.closest_point(&pt) {
Closest::SinglePoint(p) | Closest::Intersection(p) => p,
_ => continue,
};
let d = Geodesic.distance(pt, snapped);
if best.as_ref().map(|b| d < b.0).unwrap_or(true) {
best = Some((d, snapped, (start.x, start.y), (end.x, end.y), ow));
}
}
let (dist, sp, seg_start, seg_end, oneway) =
best.ok_or_else(|| "no usable roads".to_string())?;
if dist > max_d {
return Err(format!(
"nearest road is {:.1} m away (>{:.0} m limit)",
dist, max_d
));
}
let mut new_lng = sp.x();
let mut new_lat = sp.y();
if offset > 0.0 {
let lat0 = new_lat;
let m_per_lat = 111_320.0_f64;
let m_per_lng = 111_320.0_f64
* (lat0 * std::f64::consts::PI / 180.0).cos().abs().max(1e-6);
// Determine vehicle heading direction. Priority:
// 1. metadata polyline (true vehicle GPS)
// 2. road oneway tag (segment direction × oneway sign)
// 3. fall back to raw segment direction (sign-from-position later)
let metadata_heading = metadata_track
.and_then(|t| vehicle_heading_at(t, new_lng, new_lat));
let heading_known_for_side = metadata_heading.is_some() || oneway != 0;
let (dx, dy) = match metadata_heading {
Some((vx, vy)) => (vx * m_per_lng, vy * m_per_lat),
None => {
let raw_x = (seg_end.0 - seg_start.0) * m_per_lng;
let raw_y = (seg_end.1 - seg_start.1) * m_per_lat;
let s = if oneway < 0 { -1.0 } else { 1.0 };
(raw_x * s, raw_y * s)
}
};
let len = (dx * dx + dy * dy).sqrt().max(1e-9);
let px = -dy / len;
let py = dx / len;
// Use the asset's annotated side only if we actually know which way
// traffic flows (metadata or oneway). Otherwise raw segment direction
// is arbitrary and the side label would flip half the time.
let sign = if heading_known_for_side {
match side.map(|s| s.to_uppercase()) {
Some(ref s) if s == "LEFT" || s == "LHS" || s == "L" => 1.0,
Some(ref s) if s == "RIGHT" || s == "RHS" || s == "R" => -1.0,
_ => {
let ax = (lng - new_lng) * m_per_lng;
let ay = (lat - new_lat) * m_per_lat;
let dot = px * ax + py * ay;
if dot >= 0.0 { 1.0 } else { -1.0 }
}
}
} else {
let ax = (lng - new_lng) * m_per_lng;
let ay = (lat - new_lat) * m_per_lat;
let dot = px * ax + py * ay;
if dot >= 0.0 { 1.0 } else { -1.0 }
};
new_lng += sign * px * offset / m_per_lng;
new_lat += sign * py * offset / m_per_lat;
}
Ok((new_lat, new_lng))
}
/// Local vehicle heading at the metadata sample nearest to (lng, lat).
/// Returns (dx_lng, dy_lat) — unnormalized direction vector. None if track too
/// short to derive a heading.
fn vehicle_heading_at(
track: &[[f64; 2]],
lng: f64,
lat: f64,
) -> Option<(f64, f64)> {
if track.len() < 2 {
return None;
}
let mut best_i = 0usize;
let mut best_d2 = f64::INFINITY;
for (i, p) in track.iter().enumerate() {
let dx = p[0] - lng;
let dy = p[1] - lat;
let d2 = dx * dx + dy * dy;
if d2 < best_d2 {
best_d2 = d2;
best_i = i;
}
}
let a = track[best_i.saturating_sub(1)];
let b = track[(best_i + 1).min(track.len() - 1)];
Some((b[0] - a[0], b[1] - a[1]))
}
#[tauri::command]
pub async fn snap_to_road(
asset_id: i64,
max_distance_m: Option<f64>,
offset_m: Option<f64>,
metadata_track: Option<Vec<[f64; 2]>>,
state: State<'_, AppState>,
) -> Result<(f64, f64), String> {
let max_d = max_distance_m.unwrap_or(50.0);
let offset = offset_m.unwrap_or(0.0).max(0.0);
let row: Asset = sqlx::query_as(
"SELECT id, row_id, asset_type, asset_name, video_name,
lat, lng, start_lat, start_lng, end_lat, end_lng,
image_path, image_path1, image_path2, image_path3,
deleted, modified, merged_into, in_scope, side, link_pair_id, link_locked
FROM assets WHERE id = ?",
)
.bind(asset_id)
.fetch_one(&state.pool)
.await
.map_err(|e| e.to_string())?;
// Build vertex list: fixed → just mid; range → start + mid + end.
let mut targets: Vec<(&'static str, f64, f64)> = vec![];
if row.asset_type == "range" {
if let (Some(la), Some(ln)) = (row.start_lat, row.start_lng) {
targets.push(("start", la, ln));
}
if let (Some(la), Some(ln)) = (row.lat, row.lng) {
targets.push(("mid", la, ln));
}
if let (Some(la), Some(ln)) = (row.end_lat, row.end_lng) {
targets.push(("end", la, ln));
}
} else if let (Some(la), Some(ln)) = (row.lat, row.lng) {
targets.push(("mid", la, ln));
}
if targets.is_empty() {
return Err("asset has no coordinate".into());
}
// Use the asset's overall bbox padded by max_d for the R-tree candidate query.
let bbox_min_lat = targets.iter().map(|t| t.1).fold(f64::INFINITY, f64::min);
let bbox_max_lat = targets.iter().map(|t| t.1).fold(f64::NEG_INFINITY, f64::max);
let bbox_min_lng = targets.iter().map(|t| t.2).fold(f64::INFINITY, f64::min);
let bbox_max_lng = targets.iter().map(|t| t.2).fold(f64::NEG_INFINITY, f64::max);
let pad_lat = max_d / 111_320.0;
let pad_lng = max_d
/ (111_320.0
* (bbox_min_lat * std::f64::consts::PI / 180.0)
.cos()
.abs()
.max(1e-6));
let candidate_rows: Vec<(i64, String, i64)> = sqlx::query_as(
"SELECT r.id, r.geojson, COALESCE(r.oneway, 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 <= ?
AND COALESCE(r.snap_ignored, 0) = 0",
)
.bind(bbox_min_lat - pad_lat)
.bind(bbox_max_lat + pad_lat)
.bind(bbox_min_lng - pad_lng)
.bind(bbox_max_lng + pad_lng)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
if candidate_rows.is_empty() {
return Err(format!("no roads within ~{:.0} m", max_d));
}
let candidates: Vec<(LineString<f64>, i32)> = candidate_rows
.into_iter()
.filter_map(|(_, gj, ow)| parse_linestring(&gj).map(|l| (l, ow as i32)))
.collect();
let track = metadata_track.as_deref();
// Snap each vertex independently.
let mut snapped_pts: Vec<(&'static str, f64, f64)> = Vec::with_capacity(targets.len());
for (label, vlat, vlng) in &targets {
let (nlat, nlng) = snap_point_with_offset(
&candidates,
row.side.as_deref(),
track,
*vlng,
*vlat,
offset,
max_d,
)?;
snapped_pts.push((*label, nlat, nlng));
}
// Idempotency.
let no_change = snapped_pts.iter().zip(targets.iter()).all(
|((_, nl, ng), (_, ol, og))| {
Geodesic.distance(Point::new(*og, *ol), Point::new(*ng, *nl)) < 0.5
},
);
let mid_after = snapped_pts.iter().find(|(l, _, _)| *l == "mid");
let return_lat = mid_after.map(|m| m.1).unwrap_or(snapped_pts[0].1);
let return_lng = mid_after.map(|m| m.2).unwrap_or(snapped_pts[0].2);
if no_change {
return Ok((return_lat, return_lng));
}
// Persist as a "move" so it's undoable.
let before_json = serde_json::to_string(&row).map_err(|e| e.to_string())?;
let mut new_mid_lat = row.lat;
let mut new_mid_lng = row.lng;
let mut new_s_lat = row.start_lat;
let mut new_s_lng = row.start_lng;
let mut new_e_lat = row.end_lat;
let mut new_e_lng = row.end_lng;
for (label, nlat, nlng) in &snapped_pts {
match *label {
"start" => {
new_s_lat = Some(*nlat);
new_s_lng = Some(*nlng);
}
"mid" => {
new_mid_lat = Some(*nlat);
new_mid_lng = Some(*nlng);
}
"end" => {
new_e_lat = Some(*nlat);
new_e_lng = Some(*nlng);
}
_ => {}
}
}
let min_lat = min_or_nan(&[new_mid_lat, new_s_lat, new_e_lat]);
let max_lat = max_or_nan(&[new_mid_lat, new_s_lat, new_e_lat]);
let min_lng = min_or_nan(&[new_mid_lng, new_s_lng, new_e_lng]);
let max_lng = max_or_nan(&[new_mid_lng, new_s_lng, new_e_lng]);
let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?;
sqlx::query(
"UPDATE assets SET
lat = ?, lng = ?,
start_lat = ?, start_lng = ?,
end_lat = ?, end_lng = ?,
min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?,
modified = 1, modified_by = ?, modified_at = datetime('now')
WHERE id = ?",
)
.bind(new_mid_lat)
.bind(new_mid_lng)
.bind(new_s_lat)
.bind(new_s_lng)
.bind(new_e_lat)
.bind(new_e_lng)
.bind(min_lat)
.bind(max_lat)
.bind(min_lng)
.bind(max_lng)
.bind(user_id())
.bind(asset_id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
let after_json = serde_json::to_string(
&snapped_pts
.iter()
.map(|(l, la, ln)| (l.to_string(), *la, *ln))
.collect::<Vec<_>>(),
)
.map_err(|e| e.to_string())?;
// Distinct action_type so redo_action can deserialise `after` as the
// tuple list snap writes (Vec<(label, lat, lng)>) instead of MoveAfter,
// which is what plain drag uses. Undo path is the same shape (`before`
// is the full Asset snapshot) and routed via "move" | "snap_single".
sqlx::query(
"INSERT INTO action_history (action_type, scope, asset_ids, user_id, before, after, ts)
VALUES ('snap_single', 'single', ?, ?, ?, ?, datetime('now'))",
)
.bind(format!("[{}]", asset_id))
.bind(user_id())
.bind(&before_json)
.bind(&after_json)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
tx.commit().await.map_err(|e| e.to_string())?;
apply_scope_after_change(&state.pool).await?;
Ok((return_lat, return_lng))
}
#[derive(Debug, Serialize)]
pub struct BulkSnapResult {
pub snapped: usize,
pub skipped_far: usize,
pub skipped_no_geom: usize,
}
#[tauri::command]
pub async fn snap_assets_in_bbox(
min_lat: Option<f64>,
max_lat: Option<f64>,
min_lng: Option<f64>,
max_lng: Option<f64>,
video_name: Option<String>,
max_distance_m: Option<f64>,
offset_m: Option<f64>,
centerline_names: Option<Vec<String>>,
metadata_track: Option<Vec<[f64; 2]>>,
state: State<'_, AppState>,
) -> Result<BulkSnapResult, String> {
let max_d = max_distance_m.unwrap_or(50.0);
let offset = offset_m.unwrap_or(0.0).max(0.0);
let centerline: std::collections::HashSet<String> =
centerline_names.unwrap_or_default().into_iter().collect();
let road_rows: Vec<(i64, String, i64)> = sqlx::query_as(
"SELECT id, geojson, COALESCE(oneway, 0) FROM roads
WHERE COALESCE(snap_ignored, 0) = 0",
)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
if road_rows.is_empty() {
return Err("no snap-eligible roads loaded — import OSM first or unmute roads".into());
}
let roads: Vec<(LineString<f64>, i32)> = road_rows
.into_iter()
.filter_map(|(_, gj, ow)| parse_linestring(&gj).map(|l| (l, ow as i32)))
.collect();
// Build the spatial index ONCE for the whole bulk operation. Each asset
// vertex then queries a 3×3 grid window instead of scanning every road.
let snap_index = SnapIndex::build(&roads, max_d);
let assets: Vec<Asset> = if let Some(vname) = video_name.as_deref() {
sqlx::query_as(
"SELECT a.id, a.row_id, a.asset_type, a.asset_name, a.video_name,
a.lat, a.lng, a.start_lat, a.start_lng, a.end_lat, a.end_lng,
a.image_path, a.image_path1, a.image_path2, a.image_path3,
a.deleted, a.modified, a.merged_into, a.in_scope, a.side, a.link_pair_id, a.link_locked
FROM assets a
WHERE a.video_name = ? AND a.deleted = 0",
)
.bind(vname)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?
} else {
let (mn_la, mx_la, mn_ln, mx_ln) = match (min_lat, max_lat, min_lng, max_lng) {
(Some(a), Some(b), Some(c), Some(d)) => (a, b, c, d),
_ => return Err("provide either video_name or full bbox".into()),
};
sqlx::query_as(
"SELECT a.id, a.row_id, a.asset_type, a.asset_name, a.video_name,
a.lat, a.lng, a.start_lat, a.start_lng, a.end_lat, a.end_lng,
a.image_path, a.image_path1, a.image_path2, a.image_path3,
a.deleted, a.modified, a.merged_into, a.in_scope, a.side, a.link_pair_id, a.link_locked
FROM assets a JOIN assets_rtree r ON r.id = a.id
WHERE r.max_lat >= ? AND r.min_lat <= ?
AND r.max_lng >= ? AND r.min_lng <= ?
AND a.deleted = 0",
)
.bind(mn_la)
.bind(mx_la)
.bind(mn_ln)
.bind(mx_ln)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?
};
let mut snapped = 0usize;
let mut skipped_far = 0usize;
let mut skipped_no_geom = 0usize;
let mut affected_ids: Vec<i64> = vec![];
let mut before_map = serde_json::Map::new();
let mut after_map = serde_json::Map::new();
let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?;
let track = metadata_track.as_deref();
for asset in assets {
let asset_offset = if centerline.contains(&asset.asset_name) {
0.0
} else {
offset
};
let side = asset.side.as_deref();
// Build the list of vertices to snap. For fixed: just (mid).
// For range: (start, mid, end), each independently snapped/offset.
let mut targets: Vec<(&'static str, f64, f64)> = vec![];
if asset.asset_type == "range" {
if let (Some(la), Some(ln)) = (asset.start_lat, asset.start_lng) {
targets.push(("start", la, ln));
}
if let (Some(la), Some(ln)) = (asset.lat, asset.lng) {
targets.push(("mid", la, ln));
}
if let (Some(la), Some(ln)) = (asset.end_lat, asset.end_lng) {
targets.push(("end", la, ln));
}
} else if let (Some(la), Some(ln)) = (asset.lat, asset.lng) {
targets.push(("mid", la, ln));
}
if targets.is_empty() {
skipped_no_geom += 1;
continue;
}
// Snap each vertex; bail out for the asset if any vertex is too far
// (keeps the asset's three vertices in lockstep — partial snap would
// produce a kinked polyline).
let mut snapped_pts: Vec<(&'static str, f64, f64)> = vec![];
let mut bail = false;
let mut too_far = false;
for (label, vlat, vlng) in &targets {
let cands = snap_index.candidates(*vlng, *vlat);
match snap_point_with_segments(
cands,
side,
track,
*vlng,
*vlat,
asset_offset,
max_d,
) {
Ok((nlat, nlng)) => snapped_pts.push((*label, nlat, nlng)),
Err(msg) => {
if msg.contains("away") {
too_far = true;
} else {
bail = true;
}
}
}
}
if bail || snapped_pts.len() != targets.len() {
if too_far {
skipped_far += 1;
} else {
skipped_no_geom += 1;
}
continue;
}
// Idempotency: if every vertex moved <0.1m, skip.
let no_change = snapped_pts.iter().zip(targets.iter()).all(
|((_, nl, ng), (_, ol, og))| {
Geodesic.distance(Point::new(*og, *ol), Point::new(*ng, *nl))
< 0.5
},
);
if no_change {
continue;
}
before_map.insert(
asset.id.to_string(),
serde_json::to_value(&asset).unwrap_or(serde_json::Value::Null),
);
let mut after_obj = serde_json::Map::new();
let mut new_mid_lat = asset.lat;
let mut new_mid_lng = asset.lng;
let mut new_s_lat = asset.start_lat;
let mut new_s_lng = asset.start_lng;
let mut new_e_lat = asset.end_lat;
let mut new_e_lng = asset.end_lng;
for (label, nlat, nlng) in &snapped_pts {
after_obj.insert(format!("{}_lat", label), serde_json::json!(nlat));
after_obj.insert(format!("{}_lng", label), serde_json::json!(nlng));
match *label {
"start" => {
new_s_lat = Some(*nlat);
new_s_lng = Some(*nlng);
}
"mid" => {
new_mid_lat = Some(*nlat);
new_mid_lng = Some(*nlng);
}
"end" => {
new_e_lat = Some(*nlat);
new_e_lng = Some(*nlng);
}
_ => {}
}
}
after_map.insert(
asset.id.to_string(),
serde_json::Value::Object(after_obj),
);
affected_ids.push(asset.id);
let mn_lat = min_or_nan(&[new_mid_lat, new_s_lat, new_e_lat]);
let mx_lat = max_or_nan(&[new_mid_lat, new_s_lat, new_e_lat]);
let mn_lng = min_or_nan(&[new_mid_lng, new_s_lng, new_e_lng]);
let mx_lng = max_or_nan(&[new_mid_lng, new_s_lng, new_e_lng]);
sqlx::query(
"UPDATE assets SET
lat = ?, lng = ?,
start_lat = ?, start_lng = ?,
end_lat = ?, end_lng = ?,
min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?,
modified = 1, modified_by = ?, modified_at = datetime('now')
WHERE id = ?",
)
.bind(new_mid_lat)
.bind(new_mid_lng)
.bind(new_s_lat)
.bind(new_s_lng)
.bind(new_e_lat)
.bind(new_e_lng)
.bind(mn_lat)
.bind(mx_lat)
.bind(mn_lng)
.bind(mx_lng)
.bind(user_id())
.bind(asset.id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
snapped += 1;
}
if snapped > 0 {
let ids_json = serde_json::to_string(&affected_ids).map_err(|e| e.to_string())?;
let before_json =
serde_json::to_string(&before_map).map_err(|e| e.to_string())?;
let after_json =
serde_json::to_string(&after_map).map_err(|e| e.to_string())?;
sqlx::query(
"INSERT INTO action_history
(action_type, scope, asset_ids, user_id, before, after, ts)
VALUES ('bulk_snap', 'bulk', ?, ?, ?, ?, datetime('now'))",
)
.bind(&ids_json)
.bind(user_id())
.bind(&before_json)
.bind(&after_json)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
}
tx.commit().await.map_err(|e| e.to_string())?;
apply_scope_after_change(&state.pool).await?;
Ok(BulkSnapResult {
snapped,
skipped_far,
skipped_no_geom,
})
}
#[tauri::command]
pub async fn reset_database(state: State<'_, AppState>) -> Result<(), String> {
let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?;
// Trigger assets_ad clears assets_rtree as rows leave assets.
sqlx::query("DELETE FROM assets")
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
sqlx::query("DELETE FROM scope_polygons")
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
sqlx::query("DELETE FROM action_history")
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
sqlx::query("DELETE FROM roads")
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
sqlx::query("DELETE FROM video_metadata")
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
tx.commit().await.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn save_video_metadata(
tracks: std::collections::HashMap<String, Vec<[f64; 2]>>,
state: State<'_, AppState>,
) -> Result<usize, String> {
let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?;
sqlx::query("DELETE FROM video_metadata")
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
let mut written = 0usize;
for (name, track) in &tracks {
let json = serde_json::to_string(track).map_err(|e| e.to_string())?;
sqlx::query("INSERT INTO video_metadata(video_name, track_json) VALUES (?, ?)")
.bind(name)
.bind(&json)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
written += 1;
}
tx.commit().await.map_err(|e| e.to_string())?;
Ok(written)
}
#[tauri::command]
pub async fn get_video_metadata(
state: State<'_, AppState>,
) -> Result<std::collections::HashMap<String, Vec<[f64; 2]>>, String> {
let rows: Vec<(String, String)> =
sqlx::query_as("SELECT video_name, track_json FROM video_metadata")
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
let mut out: std::collections::HashMap<String, Vec<[f64; 2]>> =
std::collections::HashMap::with_capacity(rows.len());
for (name, json) in rows {
match serde_json::from_str::<Vec<[f64; 2]>>(&json) {
Ok(track) => {
out.insert(name, track);
}
Err(_) => continue,
}
}
Ok(out)
}
#[tauri::command]
pub async fn clear_video_metadata(state: State<'_, AppState>) -> Result<usize, String> {
let res = sqlx::query("DELETE FROM video_metadata")
.execute(&state.pool)
.await
.map_err(|e| e.to_string())?;
Ok(res.rows_affected() as usize)
}
#[derive(Debug, Serialize)]
pub struct DataSourceCounts {
pub fixed_assets: i64,
pub range_assets: i64,
pub osm_roads: i64,
pub scope_features: i64,
pub video_metadata: 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())?;
let (vmeta,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM video_metadata")
.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,
video_metadata: vmeta,
})
}
#[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,
state: State<'_, AppState>,
) -> Result<Vec<AuditRow>, String> {
sqlx::query_as::<_, AuditRow>(
"SELECT id, action_type, scope, asset_ids, user_id, before, after, ts, undone
FROM action_history ORDER BY id DESC LIMIT ?",
)
.bind(limit)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn redo_action(action_id: i64, state: State<'_, AppState>) -> Result<(), String> {
let row: AuditRow = sqlx::query_as(
"SELECT id, action_type, scope, asset_ids, user_id, before, after, ts, undone
FROM action_history WHERE id = ?",
)
.bind(action_id)
.fetch_one(&state.pool)
.await
.map_err(|e| e.to_string())?;
if row.undone == 0 {
return Err("action is not in an undone state".to_string());
}
let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?;
match row.action_type.as_str() {
"move" => {
let after_str = row.after.as_deref().unwrap_or("");
let mv: MoveAfter =
serde_json::from_str(after_str).map_err(|e| e.to_string())?;
let ids: Vec<i64> = serde_json::from_str(&row.asset_ids)
.map_err(|e| e.to_string())?;
let id = *ids.first().ok_or("missing asset id")?;
let asset: Asset = sqlx::query_as(
"SELECT id, row_id, asset_type, asset_name, video_name,
lat, lng, start_lat, start_lng, end_lat, end_lng,
image_path, image_path1, image_path2, image_path3,
deleted, modified, merged_into, in_scope, side, link_pair_id, link_locked
FROM assets WHERE id = ?",
)
.bind(id)
.fetch_one(&mut *tx)
.await
.map_err(|e| e.to_string())?;
let (new_lat, new_lng, new_slat, new_slng, new_elat, new_elng) =
match mv.vertex.as_str() {
"fixed" | "mid" => (
Some(mv.lat),
Some(mv.lng),
asset.start_lat,
asset.start_lng,
asset.end_lat,
asset.end_lng,
),
"start" => (
asset.lat,
asset.lng,
Some(mv.lat),
Some(mv.lng),
asset.end_lat,
asset.end_lng,
),
"end" => (
asset.lat,
asset.lng,
asset.start_lat,
asset.start_lng,
Some(mv.lat),
Some(mv.lng),
),
other => return Err(format!("invalid vertex: {}", other)),
};
let min_lat = min_or_nan(&[new_lat, new_slat, new_elat]);
let max_lat = max_or_nan(&[new_lat, new_slat, new_elat]);
let min_lng = min_or_nan(&[new_lng, new_slng, new_elng]);
let max_lng = max_or_nan(&[new_lng, new_slng, new_elng]);
sqlx::query(
"UPDATE assets SET
lat = ?, lng = ?, start_lat = ?, start_lng = ?, end_lat = ?, end_lng = ?,
min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?,
modified = 1, modified_by = ?, modified_at = datetime('now')
WHERE id = ?",
)
.bind(new_lat)
.bind(new_lng)
.bind(new_slat)
.bind(new_slng)
.bind(new_elat)
.bind(new_elng)
.bind(min_lat)
.bind(max_lat)
.bind(min_lng)
.bind(max_lng)
.bind(user_id())
.bind(id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
}
"snap_single" => {
// snap_to_road wrote `after` as Vec<(label, lat, lng)>; replay
// each labelled vertex onto its slot. 1 entry for fixed (mid),
// up to 3 for range (start/mid/end).
let after_str = row.after.as_deref().unwrap_or("[]");
let pts: Vec<(String, f64, f64)> =
serde_json::from_str(after_str).map_err(|e| e.to_string())?;
let ids: Vec<i64> = serde_json::from_str(&row.asset_ids)
.map_err(|e| e.to_string())?;
let id = *ids.first().ok_or("missing asset id")?;
let asset: Asset = sqlx::query_as(
"SELECT id, row_id, asset_type, asset_name, video_name,
lat, lng, start_lat, start_lng, end_lat, end_lng,
image_path, image_path1, image_path2, image_path3,
deleted, modified, merged_into, in_scope, side, link_pair_id, link_locked
FROM assets WHERE id = ?",
)
.bind(id)
.fetch_one(&mut *tx)
.await
.map_err(|e| e.to_string())?;
let mut new_lat = asset.lat;
let mut new_lng = asset.lng;
let mut new_slat = asset.start_lat;
let mut new_slng = asset.start_lng;
let mut new_elat = asset.end_lat;
let mut new_elng = asset.end_lng;
for (label, lat, lng) in &pts {
match label.as_str() {
"fixed" | "mid" => {
new_lat = Some(*lat);
new_lng = Some(*lng);
}
"start" => {
new_slat = Some(*lat);
new_slng = Some(*lng);
}
"end" => {
new_elat = Some(*lat);
new_elng = Some(*lng);
}
_ => {}
}
}
let min_lat = min_or_nan(&[new_lat, new_slat, new_elat]);
let max_lat = max_or_nan(&[new_lat, new_slat, new_elat]);
let min_lng = min_or_nan(&[new_lng, new_slng, new_elng]);
let max_lng = max_or_nan(&[new_lng, new_slng, new_elng]);
sqlx::query(
"UPDATE assets SET
lat = ?, lng = ?, start_lat = ?, start_lng = ?, end_lat = ?, end_lng = ?,
min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?,
modified = 1, modified_by = ?, modified_at = datetime('now')
WHERE id = ?",
)
.bind(new_lat)
.bind(new_lng)
.bind(new_slat)
.bind(new_slng)
.bind(new_elat)
.bind(new_elng)
.bind(min_lat)
.bind(max_lat)
.bind(min_lng)
.bind(max_lng)
.bind(user_id())
.bind(id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
}
"delete" | "delete_by_video" => {
let ids: Vec<i64> =
serde_json::from_str(&row.asset_ids).map_err(|e| e.to_string())?;
for id in ids {
sqlx::query(
"UPDATE assets SET deleted = 1, modified = 1,
modified_by = ?, modified_at = datetime('now')
WHERE id = ?",
)
.bind(user_id())
.bind(id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
}
}
"set_scope" => {
let after_map: serde_json::Map<String, serde_json::Value> =
serde_json::from_str(row.after.as_deref().unwrap_or("{}"))
.map_err(|e| e.to_string())?;
for (id_str, val) in after_map {
let id: i64 = id_str.parse().map_err(|_| "bad id".to_string())?;
let in_scope = val.get("in_scope").and_then(|v| v.as_i64());
let manual = val.get("manual_scope").and_then(|v| v.as_i64());
sqlx::query(
"UPDATE assets SET in_scope = ?, manual_scope = ? WHERE id = ?",
)
.bind(in_scope)
.bind(manual)
.bind(id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
}
}
"rename" => {
let after_val: serde_json::Value =
serde_json::from_str(row.after.as_deref().unwrap_or("{}"))
.map_err(|e| e.to_string())?;
let new_name = after_val
.get("asset_name")
.and_then(|v| v.as_str())
.unwrap_or("");
let ids: Vec<i64> = serde_json::from_str(&row.asset_ids)
.map_err(|e| e.to_string())?;
for id in ids {
sqlx::query("UPDATE assets SET asset_name = ? WHERE id = ?")
.bind(new_name)
.bind(id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
}
}
"change_side" => {
let after_val: serde_json::Value =
serde_json::from_str(row.after.as_deref().unwrap_or("{}"))
.map_err(|e| e.to_string())?;
let new_side = after_val
.get("side")
.and_then(|v| v.as_str())
.map(String::from);
let ids: Vec<i64> = serde_json::from_str(&row.asset_ids)
.map_err(|e| e.to_string())?;
for id in ids {
sqlx::query("UPDATE assets SET side = ? WHERE id = ?")
.bind(&new_side)
.bind(id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
}
}
"link" => {
let after_map: serde_json::Map<String, serde_json::Value> =
serde_json::from_str(row.after.as_deref().unwrap_or("{}"))
.map_err(|e| e.to_string())?;
for (id_str, val) in after_map {
let id: i64 = id_str.parse().map_err(|_| "bad id".to_string())?;
let pair = val.get("link_pair_id").and_then(|v| v.as_i64());
let locked = val
.get("link_locked")
.and_then(|v| v.as_i64())
.unwrap_or(0);
sqlx::query(
"UPDATE assets SET link_pair_id = ?, link_locked = ? WHERE id = ?",
)
.bind(pair)
.bind(locked)
.bind(id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
}
}
"restore" => {
let ids: Vec<i64> = serde_json::from_str(&row.asset_ids)
.map_err(|e| e.to_string())?;
for id in ids {
sqlx::query("UPDATE assets SET deleted = 0 WHERE id = ?")
.bind(id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
}
}
"merge" => {
let ids: Vec<i64> =
serde_json::from_str(&row.asset_ids).map_err(|e| e.to_string())?;
if ids.len() != 2 {
return Err("malformed merge audit row".to_string());
}
// Reapply the merged primary geometry from the `after` snapshot.
// Older audit rows (from before this fix) may not have `after` —
// fall back to re-deleting the secondary only.
let after_str = row.after.as_deref().unwrap_or("");
if let Ok(after) =
serde_json::from_str::<serde_json::Value>(after_str)
{
let getn = |k: &str| after.get(k).and_then(|v| v.as_f64());
if let (
Some(lat),
Some(lng),
Some(s_lat),
Some(s_lng),
Some(e_lat),
Some(e_lng),
Some(mn_lat),
Some(mx_lat),
Some(mn_lng),
Some(mx_lng),
) = (
getn("lat"),
getn("lng"),
getn("start_lat"),
getn("start_lng"),
getn("end_lat"),
getn("end_lng"),
getn("min_lat"),
getn("max_lat"),
getn("min_lng"),
getn("max_lng"),
) {
sqlx::query(
"UPDATE assets SET
lat = ?, lng = ?,
start_lat = ?, start_lng = ?,
end_lat = ?, end_lng = ?,
min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?,
modified = 1, modified_by = ?, modified_at = datetime('now')
WHERE id = ?",
)
.bind(lat)
.bind(lng)
.bind(s_lat)
.bind(s_lng)
.bind(e_lat)
.bind(e_lng)
.bind(mn_lat)
.bind(mx_lat)
.bind(mn_lng)
.bind(mx_lng)
.bind(user_id())
.bind(ids[0])
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
}
}
sqlx::query(
"UPDATE assets SET merged_into = ?, deleted = 1, modified = 1,
modified_by = ?, modified_at = datetime('now')
WHERE id = ?",
)
.bind(ids[0])
.bind(user_id())
.bind(ids[1])
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
}
"bulk_snap" => {
let after_map: serde_json::Map<String, serde_json::Value> =
serde_json::from_str(row.after.as_deref().unwrap_or("{}"))
.map_err(|e| e.to_string())?;
for (id_str, val) in after_map {
let id: i64 = id_str.parse().map_err(|_| "bad id".to_string())?;
// bulk_snap stores keys: mid_lat/mid_lng (always), and
// start_lat/start_lng + end_lat/end_lng for range assets. Read
// the current row so we can leave any vertex untouched if the
// snap didn't supply that label.
let cur: Asset = sqlx::query_as(
"SELECT id, row_id, asset_type, asset_name, video_name,
lat, lng, start_lat, start_lng, end_lat, end_lng,
image_path, image_path1, image_path2, image_path3,
deleted, modified, merged_into, in_scope, side, link_pair_id, link_locked
FROM assets WHERE id = ?",
)
.bind(id)
.fetch_one(&mut *tx)
.await
.map_err(|e| e.to_string())?;
let read = |k: &str| val.get(k).and_then(|v| v.as_f64());
let new_lat = read("mid_lat").or(cur.lat);
let new_lng = read("mid_lng").or(cur.lng);
let new_slat = read("start_lat").or(cur.start_lat);
let new_slng = read("start_lng").or(cur.start_lng);
let new_elat = read("end_lat").or(cur.end_lat);
let new_elng = read("end_lng").or(cur.end_lng);
let mn_lat = min_or_nan(&[new_lat, new_slat, new_elat]);
let mx_lat = max_or_nan(&[new_lat, new_slat, new_elat]);
let mn_lng = min_or_nan(&[new_lng, new_slng, new_elng]);
let mx_lng = max_or_nan(&[new_lng, new_slng, new_elng]);
sqlx::query(
"UPDATE assets SET
lat = ?, lng = ?,
start_lat = ?, start_lng = ?,
end_lat = ?, end_lng = ?,
min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?,
modified = 1, modified_by = ?, modified_at = datetime('now')
WHERE id = ?",
)
.bind(new_lat)
.bind(new_lng)
.bind(new_slat)
.bind(new_slng)
.bind(new_elat)
.bind(new_elng)
.bind(mn_lat)
.bind(mx_lat)
.bind(mn_lng)
.bind(mx_lng)
.bind(user_id())
.bind(id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
}
}
"bulk_translate" => {
// Re-apply the post-translate state captured at action time.
let after_map: serde_json::Map<String, serde_json::Value> =
serde_json::from_str(row.after.as_deref().unwrap_or("{}"))
.map_err(|e| e.to_string())?;
for (id_str, val) in after_map {
let id: i64 = id_str.parse().map_err(|_| "bad id".to_string())?;
let lat = val.get("lat").and_then(|v| v.as_f64());
let lng = val.get("lng").and_then(|v| v.as_f64());
let slat = val.get("start_lat").and_then(|v| v.as_f64());
let slng = val.get("start_lng").and_then(|v| v.as_f64());
let elat = val.get("end_lat").and_then(|v| v.as_f64());
let elng = val.get("end_lng").and_then(|v| v.as_f64());
let lats: Vec<f64> = [lat, slat, elat].into_iter().flatten().collect();
let lngs: Vec<f64> = [lng, slng, elng].into_iter().flatten().collect();
if lats.is_empty() || lngs.is_empty() {
continue;
}
let mn_lat = lats.iter().cloned().fold(f64::INFINITY, f64::min);
let mx_lat = lats.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let mn_lng = lngs.iter().cloned().fold(f64::INFINITY, f64::min);
let mx_lng = lngs.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
sqlx::query(
"UPDATE assets SET
lat = ?, lng = ?, start_lat = ?, start_lng = ?,
end_lat = ?, end_lng = ?,
min_lat = ?, max_lat = ?, min_lng = ?, max_lng = ?,
modified = 1, modified_by = ?, modified_at = datetime('now')
WHERE id = ?",
)
.bind(lat)
.bind(lng)
.bind(slat)
.bind(slng)
.bind(elat)
.bind(elng)
.bind(mn_lat)
.bind(mx_lat)
.bind(mn_lng)
.bind(mx_lng)
.bind(user_id())
.bind(id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
}
}
other => return Err(format!("unknown action_type: {}", other)),
}
sqlx::query("UPDATE action_history SET undone = 0 WHERE id = ?")
.bind(action_id)
.execute(&mut *tx)
.await
.map_err(|e| e.to_string())?;
tx.commit().await.map_err(|e| e.to_string())?;
apply_scope_after_change(&state.pool).await?;
Ok(())
}
#[tauri::command]
pub async fn list_assets(state: State<'_, AppState>) -> Result<Vec<Asset>, String> {
sqlx::query_as::<_, Asset>(
"SELECT id, row_id, asset_type, asset_name, video_name,
lat, lng, start_lat, start_lng, end_lat, end_lng,
image_path, image_path1, image_path2, image_path3,
deleted, modified, merged_into, in_scope, side, link_pair_id, link_locked
FROM assets WHERE deleted = 0",
)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())
}
// Bbox-overlap query via R-tree.
// NOTE: does not handle antimeridian crossings — fine for current Qatar data.
#[tauri::command]
pub async fn get_assets_in_bbox(
min_lat: f64,
max_lat: f64,
min_lng: f64,
max_lng: f64,
include_deleted: Option<bool>,
state: State<'_, AppState>,
) -> Result<Vec<Asset>, String> {
let include = include_deleted.unwrap_or(false);
let sql = if include {
"SELECT a.id, a.row_id, a.asset_type, a.asset_name, a.video_name,
a.lat, a.lng, a.start_lat, a.start_lng, a.end_lat, a.end_lng,
a.image_path, a.image_path1, a.image_path2, a.image_path3,
a.deleted, a.modified, a.merged_into, a.in_scope, a.side, a.link_pair_id, a.link_locked
FROM assets a
JOIN assets_rtree r ON r.id = a.id
WHERE r.max_lat >= ? AND r.min_lat <= ?
AND r.max_lng >= ? AND r.min_lng <= ?"
} else {
"SELECT a.id, a.row_id, a.asset_type, a.asset_name, a.video_name,
a.lat, a.lng, a.start_lat, a.start_lng, a.end_lat, a.end_lng,
a.image_path, a.image_path1, a.image_path2, a.image_path3,
a.deleted, a.modified, a.merged_into, a.in_scope, a.side, a.link_pair_id, a.link_locked
FROM assets a
JOIN assets_rtree r ON r.id = a.id
WHERE r.max_lat >= ? AND r.min_lat <= ?
AND r.max_lng >= ? AND r.min_lng <= ?
AND a.deleted = 0"
};
sqlx::query_as::<_, Asset>(sql)
.bind(min_lat)
.bind(max_lat)
.bind(min_lng)
.bind(max_lng)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct AssetCoord {
pub id: i64,
pub lat: Option<f64>,
pub lng: Option<f64>,
}
#[tauri::command]
pub async fn assets_coords_for_ids(
ids: Vec<i64>,
state: State<'_, AppState>,
) -> Result<Vec<AssetCoord>, String> {
let mut out: Vec<AssetCoord> = Vec::with_capacity(ids.len());
for chunk in ids.chunks(500) {
let placeholders = vec!["?"; chunk.len()].join(",");
let sql = format!(
"SELECT id, lat, lng FROM assets WHERE id IN ({})",
placeholders
);
let mut q = sqlx::query_as::<_, AssetCoord>(&sql);
for id in chunk {
q = q.bind(id);
}
let part = q
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
out.extend(part);
}
Ok(out)
}
// ----------------------------------------------------------------------------
// Quality report — surfaces the most common data-issue categories so users
// can audit a dataset proactively instead of stumbling on problems while
// reviewing. MVP: moved-far, impossible row_id jumps, missing side.
// ----------------------------------------------------------------------------
#[derive(Debug, Serialize)]
pub struct QualityReport {
/// Modified rows whose current position is > moved_threshold_m from
/// the original (import-time) position.
pub moved_far: Vec<i64>,
/// Rows whose current position is > metadata_threshold_m from the
/// nearest segment of their video's metadata polyline. Only checked
/// for videos whose metadata has been loaded — otherwise skipped.
pub far_from_metadata: Vec<i64>,
/// Rows with `side IS NULL`.
pub missing_side: Vec<i64>,
pub moved_far_total: usize,
pub far_from_metadata_total: usize,
pub missing_side_total: usize,
}
// Cheap haversine in metres — fine at the thresholds we're checking.
fn hav_m(a_lat: f64, a_lng: f64, b_lat: f64, b_lng: f64) -> f64 {
let r = 6_371_000.0_f64;
let p1 = a_lat.to_radians();
let p2 = b_lat.to_radians();
let dp = (b_lat - a_lat).to_radians();
let dl = (b_lng - a_lng).to_radians();
let s = (dp / 2.0).sin().powi(2)
+ p1.cos() * p2.cos() * (dl / 2.0).sin().powi(2);
2.0 * r * s.sqrt().asin()
}
#[tauri::command]
pub async fn quality_report(
moved_threshold_m: Option<f64>,
metadata_threshold_m: Option<f64>,
metadata: Option<std::collections::HashMap<String, Vec<[f64; 2]>>>,
cap: Option<usize>,
state: State<'_, AppState>,
) -> Result<QualityReport, String> {
// Defaults — conservative so you only see real signal. The
// outlier-from-neighbours check was dropped: without a real reference
// (which the metadata polyline already provides) the only honest
// signal is far-from-metadata; gap- or chord-based heuristics produce
// thousands of false positives on multimodal / curved / cross-side
// data and aren't worth the noise.
let moved_t = moved_threshold_m.unwrap_or(50.0);
let metadata_t = metadata_threshold_m.unwrap_or(30.0);
let cap = cap.unwrap_or(500);
let metadata = metadata.unwrap_or_default();
// --- Moved far from import ---
// modified=1 means the user has touched this row. We compare current vs
// orig coords; rows without orig (pre-migration data) are skipped.
let moved_rows: Vec<(i64, Option<f64>, Option<f64>, Option<f64>, Option<f64>)> =
sqlx::query_as(
"SELECT id, lat, lng, orig_lat, orig_lng
FROM assets
WHERE deleted = 0 AND modified = 1
AND lat IS NOT NULL AND lng IS NOT NULL
AND orig_lat IS NOT NULL AND orig_lng IS NOT NULL",
)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
let mut moved_far: Vec<i64> = Vec::new();
let mut moved_far_total: usize = 0;
for (id, lat, lng, ol, og) in moved_rows {
if let (Some(la), Some(ln), Some(ola), Some(olg)) = (lat, lng, ol, og) {
if hav_m(la, ln, ola, olg) >= moved_t {
moved_far_total += 1;
if moved_far.len() < cap {
moved_far.push(id);
}
}
}
}
// --- Missing side ---
let missing_rows: Vec<(i64,)> = sqlx::query_as(
"SELECT id FROM assets WHERE deleted = 0 AND side IS NULL",
)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
let missing_side_total = missing_rows.len();
let missing_side: Vec<i64> = missing_rows
.into_iter()
.take(cap)
.map(|(id,)| id)
.collect();
// --- Pull every live asset once for the next two checks. ---
let video_rows: Vec<(i64, String, String, Option<f64>, Option<f64>)> =
sqlx::query_as(
"SELECT id, video_name, row_id, lat, lng
FROM assets
WHERE deleted = 0
AND lat IS NOT NULL AND lng IS NOT NULL",
)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
use std::collections::HashMap;
let mut by_video: HashMap<String, Vec<(i64, String, f64, f64)>> =
HashMap::new();
for (id, video, row_id, lat, lng) in video_rows {
if let (Some(la), Some(ln)) = (lat, lng) {
by_video.entry(video).or_default().push((id, row_id, la, ln));
}
}
// --- Far from metadata polyline ---
// Per-asset min point-to-segment distance to the asset's video's
// metadata track. `metadata` arrives as `{video_name: [[lng, lat], ...]}`.
// Skipped silently when the asset's video has no metadata loaded.
let mut far_from_metadata: Vec<i64> = Vec::new();
let mut far_from_metadata_total: usize = 0;
for (video, rows) in &by_video {
let Some(track) = metadata.get(video) else {
continue;
};
if track.len() < 2 {
continue;
}
for (id, _row_id, lat, lng) in rows {
let mut min_d = f64::INFINITY;
for w in track.windows(2) {
let d = point_segment_distance_m(
*lng, *lat, w[0][0], w[0][1], w[1][0], w[1][1],
);
if d < min_d {
min_d = d;
if min_d < 1.0 {
break;
}
}
}
if min_d.is_finite() && min_d >= metadata_t {
far_from_metadata_total += 1;
if far_from_metadata.len() < cap {
far_from_metadata.push(*id);
}
}
}
}
// by_video was used only by the dropped outlier check; release it.
drop(by_video);
Ok(QualityReport {
moved_far,
far_from_metadata,
missing_side,
moved_far_total,
far_from_metadata_total,
missing_side_total,
})
}
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct AssetBbox {
pub asset_id: i64,
pub slot: String,
pub x1: f64,
pub y1: f64,
pub x2: f64,
pub y2: f64,
// The class label is the asset's name. Reported here so the frontend
// doesn't have to plumb assets[asset_id].asset_name separately, but the
// source of truth is `assets.asset_name` — rename via rename_assets.
pub class: Option<String>,
}
fn normalize_slot(slot: &str) -> Result<&'static str, String> {
match slot {
"fixed" => Ok("fixed"),
"start" => Ok("start"),
"mid" => Ok("mid"),
"end" => Ok("end"),
other => Err(format!("invalid bbox slot: {other}")),
}
}
#[tauri::command]
pub async fn get_asset_bboxes(
asset_id: i64,
state: State<'_, AppState>,
) -> Result<Vec<AssetBbox>, String> {
sqlx::query_as::<_, AssetBbox>(
"SELECT b.asset_id, b.slot, b.x1, b.y1, b.x2, b.y2,
a.asset_name AS class
FROM asset_bboxes b JOIN assets a ON a.id = b.asset_id
WHERE b.asset_id = ? ORDER BY b.slot",
)
.bind(asset_id)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn set_asset_bbox(
asset_id: i64,
slot: String,
x1: f64,
y1: f64,
x2: f64,
y2: f64,
state: State<'_, AppState>,
) -> Result<(), String> {
let slot = normalize_slot(&slot)?;
// Normalize so x1 <= x2 and y1 <= y2 so render math doesn't have to.
let (nx1, nx2) = if x1 <= x2 { (x1, x2) } else { (x2, x1) };
let (ny1, ny2) = if y1 <= y2 { (y1, y2) } else { (y2, y1) };
if (nx2 - nx1).abs() < 1.0 || (ny2 - ny1).abs() < 1.0 {
return Err("bbox too small (need ≥ 1 px in each axis)".into());
}
sqlx::query(
"INSERT INTO asset_bboxes(asset_id, slot, x1, y1, x2, y2)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(asset_id, slot) DO UPDATE SET
x1 = excluded.x1, y1 = excluded.y1,
x2 = excluded.x2, y2 = excluded.y2",
)
.bind(asset_id)
.bind(slot)
.bind(nx1)
.bind(ny1)
.bind(nx2)
.bind(ny2)
.execute(&state.pool)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn clear_asset_bbox(
asset_id: i64,
slot: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let slot = normalize_slot(&slot)?;
sqlx::query("DELETE FROM asset_bboxes WHERE asset_id = ? AND slot = ?")
.bind(asset_id)
.bind(slot)
.execute(&state.pool)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub async fn export_bboxes_to_json(
path: String,
state: State<'_, AppState>,
) -> Result<usize, String> {
// Sidecar shape: one entry per (row_id, slot). The original JSON's bbox_*
// keys stay shallow — we emit a flat `slot` field so consumers don't have
// to know the asset_type ahead of time. Class is asset-level so every
// entry for the same row_id carries the same value.
let rows: Vec<(String, String, String, f64, f64, f64, f64, Option<String>)> =
sqlx::query_as(
"SELECT a.row_id, a.video_name, b.slot, b.x1, b.y1, b.x2, b.y2, a.asset_name
FROM asset_bboxes b JOIN assets a ON a.id = b.asset_id
ORDER BY a.row_id, b.slot",
)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
let entries: Vec<serde_json::Value> = rows
.into_iter()
.map(|(row_id, video_name, slot, x1, y1, x2, y2, class)| {
serde_json::json!({
"row_id": row_id,
"video_name": video_name,
"slot": slot,
"bbox_x1": x1,
"bbox_y1": y1,
"bbox_x2": x2,
"bbox_y2": y2,
"class": class,
})
})
.collect();
let n = entries.len();
let out = serde_json::json!({ "bboxes": entries });
let body = serde_json::to_vec_pretty(&out).map_err(|e| e.to_string())?;
tokio::fs::write(&path, body).await.map_err(|e| e.to_string())?;
Ok(n)
}