From 2139a908e67329209df0db5e9be1b76482a12ec7 Mon Sep 17 00:00:00 2001 From: ravi Date: Sat, 2 May 2026 11:03:27 +0530 Subject: [PATCH] osm_bug fix --- TEST_PLAN.txt | 326 +++++++++++++++ src-tauri/src/commands.rs | 844 +++++++++++++++++++++++++++++++------- src-tauri/src/db.rs | 4 +- src-tauri/src/lib.rs | 7 +- src/App.tsx | 156 +++++-- 5 files changed, 1167 insertions(+), 170 deletions(-) create mode 100644 TEST_PLAN.txt diff --git a/TEST_PLAN.txt b/TEST_PLAN.txt new file mode 100644 index 0000000..d85c56d --- /dev/null +++ b/TEST_PLAN.txt @@ -0,0 +1,326 @@ +KML MAP TOOL — DIAGNOSTIC TEST PLAN +==================================== + +Goal: isolate where pan/zoom lag actually originates (WebView vs app code vs +data scale) before deciding which optimisation to invest in next. + +How to use: run sections in the order at the bottom of this file. Record FPS +and console-counter numbers as you go. The numbers — not feel — decide what +ships next. + +------------------------------------------------------------------------------ +A. WEBVIEW BASELINE TESTS (do first, cheapest) +------------------------------------------------------------------------------ + +A1. Confirm GPU acceleration + + Command: glxinfo | grep "OpenGL renderer" + + Pass: Output names a real GPU + (e.g. "Mesa Intel(R) HD Graphics 630", + "NVIDIA GeForce RTX 5070/PCIe/SSE2"). + Fail: Output says "llvmpipe" (software rendering). + + If fail: No code change will help. Fix drivers / Mesa / permissions + first. Stop here. + + +A2. WebKitGTK DMA-BUF mitigation + + Run two sessions and compare FPS at the same dataset/zoom: + Baseline: npm run tauri dev + Mitigated: WEBKIT_DISABLE_DMABUF_RENDERER=1 npm run tauri dev + + Expected: FPS jumps >= 1.5x with the env var set on Linux + Mesa + setups suffering DMA-BUF compositor regressions. + + If pass: WebKitGTK compositor is the bottleneck. Document the env + var in INSTALLATION.md as a required workaround. + + +A3. Tauri WKGTK vs Chromium A/B + + Terminal 1 (Tauri / WebKitGTK): + npm run tauri dev + Pan/zoom over a fixed area at z=14 for 5 seconds, record FPS. + + Terminal 2 (vanilla browser): + npm run dev + Open http://localhost:1420 in Firefox or Chromium. + Login as "browser-test" (synthetic ~5000 dots around Hyderabad). + Settings -> Performance mode ON (FPS counter top-centre). + Pan/zoom at z=14 for 5 seconds, record FPS. + + Compare: same code, different WebView. Big delta = WebView is the + bottleneck. Small delta = the issue is below the WebView + (drivers/compositor/dataset). + + +A4. Same build, cross-OS (if Windows machine available) + + Build / run the Tauri app on Windows (uses WebView2 = Chromium- + based). Repeat A3 measurement with the same dataset. + + Pass: Windows holds steady FPS while Linux does not -> WebKitGTK + is conclusively the issue on the user's Linux box. + + +------------------------------------------------------------------------------ +B. APP-LEVEL ISOLATION (uses perfMode + console counters) +------------------------------------------------------------------------------ + +B1. perfMode on/off A/B + + 1. Settings -> Performance mode OFF. Pan/zoom 5 seconds. Record FPS. + 2. Settings -> Performance mode ON. Pan/zoom 5 seconds. Record FPS. + + Big jump (>= +20 FPS): Layer complexity matters. Move to test + B3 to find the most expensive layer. + No change: Bottleneck is below the layer effect + (WebView / Mesa / compositor). Move to A2/A3. + + +B2. Console-count check + + 1. Open DevTools console. + 2. Pan continuously for 5 seconds. + + Expected: + "MapView render" increments by ~2 per gesture + (movestart + moveend toggles). + "deck layer rebuild" increments by <= 2 per gesture. + + Fail mode: either counter increments by tens or hundreds during + a single pan -> a regression has snuck in. Investigate. + + +B3. Layer-by-layer cost ladder (with perfMode OFF) + + Toggle right-panel Layer checkboxes off one at a time. + Record FPS after each step: + + All on (baseline) FPS = ____ + Scope off FPS = ____ + Metadata off FPS = ____ + OSM off FPS = ____ + Range off FPS = ____ + Fixed off FPS = ____ + + The biggest single-step jump = the layer to simplify first. + + +B4. Filter-down ladder + + 1. Filter to a single video. Record FPS. + 2. Then filter to a single asset_name. Record FPS. + 3. Then zoom in by 2 levels. Record FPS. + + If FPS climbs sharply, dataset density is the load and the answer + is zoom-tier sampling, not layer code. + + +------------------------------------------------------------------------------ +C. SCENARIO REGRESSION TESTS (verify recent fixes) +------------------------------------------------------------------------------ + +C1. OSM import auto-recentre + + 1. Open app fresh (default viewport = [51.4, 25.5], near Qatar). + 2. Do NOT pan. + 3. Import an OSM GeoJSON for a far-away region (e.g. Hyderabad). + + Expected: map flies to imported region, roads visible immediately, + status bar shows "Loaded N road segment(s). Centred on + imported area." + + Fail: roads invisible after import. Was the user-reported bug + "imports 10-15 times before showing". + + +C2. Pan/zoom doesn't churn React + + 1. Open DevTools -> React DevTools -> Profiler. + 2. Click record. Pan continuously 5 seconds. Stop record. + + Expected: <= 2 AppShell renders per gesture (movestart + moveend). + Fail: ~60 renders/sec -> the move-sync regression is back. + + +C3. Decorative overlays hide during gestures + + 1. Tap a metadata polyline to select it (arrows, S/E rings, name + badge appear). + 2. Begin a pan or zoom. + + Expected: arrows, name badge, S/E rings/labels, link halos disappear + during the gesture and reappear on moveend. + + +C4. zoomBand quantisation + + 1. Open DevTools console (counters visible from B2). + 2. Mouse-wheel zoom by tiny increments within one 0.5-zoom band + (e.g. 14.0 -> 14.1 -> 14.2 -> 14.3). + + Expected: "deck layer rebuild" does NOT increment for sub-band moves. + It should only increment when crossing a 0.5 boundary. + + +C5. Stale-fetch guard + + 1. Pan rapidly across many tiles for 10 seconds without stopping. + + Expected: assets array updates only with the latest viewport's data. + No flicker of old viewports' points appearing late. + + +C6. Compare mode default off + + 1. Open app fresh. + 2. Inspect .map-area in DevTools. + + Expected: exactly one child rendered. Toggle the Compare + checkbox in the Basemap panel and a second canvas mounts; + uncheck it and the second canvas unmounts. + + +C7. Browser-mode shim + + 1. npm run dev (no Tauri). + 2. Open http://localhost:1420 in Firefox or Chromium. + + Expected: + - Login screen lists one user "Browser Test", no console errors. + - After login, ~5000 dots visible around Hyderabad. + - Pan/zoom works. + - Filtering, search, perfMode toggle all work. + - Edit operations log "[browser-mode] invoke(...) is not stubbed" + warnings — expected, those mutations are no-ops in browser mode. + + +------------------------------------------------------------------------------ +D. STRESS / SCALE TESTS +------------------------------------------------------------------------------ + +D1. 50k assets in viewport + + 1. Import the largest realistic dataset. + 2. Zoom out so most points are in viewport. + 3. Pan across — measure FPS and frame consistency. + + Acceptable: >= 30 FPS with perfMode OFF + >= 50 FPS with perfMode ON + + +D2. Dense OSM (no class filter) + + 1. Import a full-city OSM GeoJSON WITHOUT the major-only filter + (i.e. accept all road classes). + 2. Zoom out to z=12. + + If FPS tanks: zoom-tier simplification (Test 4 plan in chat) or + class-prune workflow is required for that workload. + + +D3. Many metadata polylines + + 1. Import the per-video metadata JSON with 40+ tracks. + 2. Zoom out to fit all tracks. Pan. + + With viewport-cull + zoom-decimation already in place, should still + be smooth. If not, lower the per-track point cap or the global + arrow cap. + + +D4. Long edit session (memory leak check) + + 1. Drag 100 asset pins in succession. + 2. Mix snap-to-road, side-toggle, rename, delete, restore, undo. + 3. Watch memory in DevTools. + + Expected: heap stays stable. No leaks from event listeners or + MapLibre Marker objects. + + +------------------------------------------------------------------------------ +E. FEATURE SMOKE TESTS (run before any release) +------------------------------------------------------------------------------ + +For each, confirm the feature still works after recent edits. + + 1. Login + create user + switch user + 2. Import fixed assets JSON -> markers visible + 3. Import range assets JSON -> S/M/E lines visible + 4. Import KML scope -> polygon visible, + out-of-scope filter works + 5. Import per-video metadata JSON -> coloured polylines visible + 6. Import OSM GeoJSON -> roads visible AND map + auto-centres (see C1) + 7. Click asset -> image popup opens + (popupEnabled on) + 8. Drag pin -> asset moves, action appears + in Recent actions + 9. Lasso (circle / rect / polygon) -> bulk delete / set-scope / + restore work + 10. Find duplicates within viewport + 11. Mark anchor -> drag -> Distribute correction + 12. Auto-link in polygon (L<->R, by-video, nearest) + 13. Right-click two assets -> manual link, pink line + Right-click them again -> unlink + 14. OSM edit mode -> drag vertex, shift-click + Del, + simplify slider all work + 15. Search by lat/lng -> red goto pin appears + 16. Search by video name -> flies to video, highlights + metadata track + 17. Export Source JSON / GeoJSON / KML / CSV + Round-trip: re-import the Source JSON cleanly + 18. Settings: toggle popup, aspect ratio, snap-to-centerline names, + perfMode + 19. Undo last action -> state restored + 20. Reset DB -> app empty, but classes.txt + suggestions still load + + +------------------------------------------------------------------------------ +RECOMMENDED RUN ORDER (fastest signal first) +------------------------------------------------------------------------------ + + 1. A1, A2 (5 min, no code touched) + 2. B2 (1 min — confirms recent perf changes are working) + 3. B1 (2 min — quantifies what perfMode buys) + 4. A3 (10 min — Tauri vs Chromium) + 5. B3 (10 min — which layer is most expensive) + 6. C1 (1 min — verifies the OSM auto-recentre fix) + 7. D1, D2, D3 (only if A2/A3 inconclusive) + 8. E (full smoke) (before any release) + + +------------------------------------------------------------------------------ +DECISION TREE +------------------------------------------------------------------------------ + + A2 alone fixes the user's lag -> ship the env var in + INSTALLATION.md, done. + + A3 shows Chromium is dramatically faster -> plan browser-mode + deployment for Linux + QA reviewers (keep + Tauri for the import + operator). + + B3 isolates one heavy layer -> zoom-tier + simplification for + that layer only. + + B1 jump is large but A3 delta is small -> invest in zoom-tier + simplification + (Test 4 plan). + + None of the above conclusive -> re-run on the actual + user hardware before + investing further. + + +============================================================================== +End of test plan. +============================================================================== diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 72ddf1f..7bbd02b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -197,18 +197,6 @@ fn normalize_side(s: &str) -> Option { } } -fn detect_side(paths: &[Option<&str>]) -> Option { - for p in paths.iter().flatten() { - if p.contains("/LHS/") || p.contains("_LHS_") || p.contains("/Left/") { - return Some("Left".into()); - } - if p.contains("/RHS/") || p.contains("_RHS_") || p.contains("/Right/") { - return Some("Right".into()); - } - } - None -} - #[tauri::command] pub async fn import_fixed_assets( path: String, @@ -222,11 +210,13 @@ pub async fn import_fixed_assets( let mut count = 0usize; for input in parsed { let (lat, lng) = input.coord.to_lat_lng()?; - let side = input - .side - .as_deref() - .and_then(normalize_side) - .or_else(|| detect_side(&[input.image_path.as_deref()])); + // Side comes from the JSON only. We used to fall back to scanning + // image_path for /LHS/ or /RHS/, but those segments are storage + // folders in some pipelines, not side indicators — that produced + // confidently-wrong "Left" labels for every row. If the source has + // no side, we leave it NULL; the user can set it via the per-asset + // toggle or Lasso → Set Left / Set Right. + let side = input.side.as_deref().and_then(normalize_side); sqlx::query( r#" INSERT INTO assets ( @@ -248,7 +238,8 @@ pub async fn import_fixed_assets( image_path = excluded.image_path, -- Re-import never resurrects rows the user explicitly deleted. deleted = CASE WHEN assets.deleted = 1 THEN 1 ELSE excluded.deleted END, - side = excluded.side + -- Preserve manual side fixes (modified=1) across re-imports. + side = CASE WHEN assets.modified = 0 THEN excluded.side ELSE assets.side END "#, ) .bind(&input.row_id) @@ -306,17 +297,8 @@ pub async fn import_range_assets( 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) - .or_else(|| { - detect_side(&[ - input.image_path1.as_deref(), - input.image_path2.as_deref(), - input.image_path3.as_deref(), - ]) - }); + // See note in import_fixed_assets: side comes from the JSON only. + let side = input.side.as_deref().and_then(normalize_side); sqlx::query( r#" @@ -345,7 +327,8 @@ pub async fn import_range_assets( image_path2 = excluded.image_path2, image_path3 = excluded.image_path3, deleted = CASE WHEN assets.deleted = 1 THEN 1 ELSE excluded.deleted END, - side = excluded.side + -- Preserve manual side fixes (modified=1) across re-imports. + side = CASE WHEN assets.modified = 0 THEN excluded.side ELSE assets.side END "#, ) .bind(&input.row_id) @@ -1110,15 +1093,34 @@ pub async fn merge_polylines( .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, ts) - VALUES ('merge', 'merge', ?, ?, ?, datetime('now'))", + "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())?; @@ -1141,7 +1143,8 @@ pub async fn undo_action(action_id: i64, state: State<'_, AppState>) -> Result<( let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; match row.action_type.as_str() { - "move" => { + // 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("{}"), ) @@ -1503,29 +1506,42 @@ pub struct RoadOut { pub geojson: String, } +// 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( - pool: &SqlitePool, + conn: &mut sqlx::SqliteConnection, ids: &[i64], ) -> Result, String> { - if ids.is_empty() { - return Ok(serde_json::Map::new()); - } - let placeholders = vec!["?"; ids.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, Option)>(&sql); - for id in ids { - q = q.bind(id); - } - let rows = q.fetch_all(pool).await.map_err(|e| e.to_string())?; let mut m = serde_json::Map::new(); - for (id, in_scope, manual) in rows { - m.insert( - id.to_string(), - serde_json::json!({"in_scope": in_scope, "manual_scope": manual}), + 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, Option)>(&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) } @@ -1539,7 +1555,9 @@ pub async fn set_assets_in_scope( if ids.is_empty() { return Ok(0); } - let before = snapshot_scope_state(&state.pool, &ids).await?; + 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 = ?, \ @@ -1555,7 +1573,9 @@ pub async fn set_assets_in_scope( q = q.bind(id); } let res = q.execute(&mut *tx).await.map_err(|e| e.to_string())?; - let after = snapshot_scope_state(&state.pool, &ids).await?; + // 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())?; @@ -1582,7 +1602,9 @@ pub async fn clear_manual_scope( if ids.is_empty() { return Ok(0); } - let before = snapshot_scope_state(&state.pool, &ids).await?; + 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 ({})", @@ -1594,7 +1616,9 @@ pub async fn clear_manual_scope( } let res = q.execute(&state.pool).await.map_err(|e| e.to_string())?; apply_scope_after_change(&state.pool).await?; - let after = snapshot_scope_state(&state.pool, &ids).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())?; @@ -1997,29 +2021,32 @@ pub struct AutoLinkResult { pub leftover_b: usize, } +// Same shape as snapshot_scope_state — see notes there. async fn snapshot_link_state( - pool: &SqlitePool, + conn: &mut sqlx::SqliteConnection, ids: &[i64], ) -> Result, String> { - if ids.is_empty() { - return Ok(serde_json::Map::new()); - } - let placeholders = vec!["?"; ids.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, Option)>(&sql); - for id in ids { - q = q.bind(id); - } - let rows = q.fetch_all(pool).await.map_err(|e| e.to_string())?; let mut m = serde_json::Map::new(); - for (id, pair, locked) in rows { - m.insert( - id.to_string(), - serde_json::json!({"link_pair_id": pair, "link_locked": locked.unwrap_or(0)}), + 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, Option)>(&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) } @@ -2198,7 +2225,36 @@ pub async fn auto_link_in_polygon( affected_ids.push(*a); affected_ids.push(*b); } - let before = snapshot_link_state(&state.pool, &affected_ids).await?; + // 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,)>(&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). @@ -2226,7 +2282,8 @@ pub async fn auto_link_in_polygon( .await .map_err(|e| e.to_string())?; } - let after = snapshot_link_state(&state.pool, &affected_ids).await?; + // 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) @@ -2272,7 +2329,9 @@ pub async fn set_link( } } } - let before = snapshot_link_state(&state.pool, &all_ids).await?; + 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 { @@ -2295,7 +2354,7 @@ pub async fn set_link( .execute(&mut *tx) .await .map_err(|e| e.to_string())?; - let after = snapshot_link_state(&state.pool, &all_ids).await?; + 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'))", @@ -2323,7 +2382,9 @@ pub async fn clear_link(id: i64, state: State<'_, AppState>) -> Result<(), Strin if let Some(p) = row.0 { all_ids.push(p); } - let before = snapshot_link_state(&state.pool, &all_ids).await?; + 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 = ?") @@ -2332,7 +2393,7 @@ pub async fn clear_link(id: i64, state: State<'_, AppState>) -> Result<(), Strin .await .map_err(|e| e.to_string())?; } - let after = snapshot_link_state(&state.pool, &all_ids).await?; + 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'))", @@ -2362,31 +2423,45 @@ pub async fn change_assets_side( "RIGHT" | "RHS" | "R" => "Right", _ => return Err(format!("invalid side: {}", side)), }; - let placeholders = vec!["?"; ids.len()].join(","); - let before_sql = format!( - "SELECT id, side FROM assets WHERE id IN ({})", - placeholders - ); - let mut bq = sqlx::query_as::<_, (i64, Option)>(&before_sql); - for id in &ids { - bq = bq.bind(id); - } - let prior = bq.fetch_all(&state.pool).await.map_err(|e| e.to_string())?; + // Chunked at 500 to stay under SQLite's 999 bind-param ceiling. let mut before_map = serde_json::Map::new(); - for (id, side_v) in &prior { - before_map.insert(id.to_string(), serde_json::json!({"side": side_v})); + 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)>(&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 upd_sql = format!( - "UPDATE assets SET side = ?, modified = 1, modified_by = ?, \ - modified_at = datetime('now') WHERE id IN ({})", - placeholders - ); - let mut q = sqlx::query(&upd_sql).bind(normalized).bind(user_id()); - for id in &ids { - q = q.bind(id); + 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(); } - let res = q.execute(&mut *tx).await.map_err(|e| e.to_string())?; + // 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())?; @@ -2403,7 +2478,7 @@ pub async fn change_assets_side( .await .map_err(|e| e.to_string())?; tx.commit().await.map_err(|e| e.to_string())?; - Ok(res.rows_affected() as usize) + Ok(res as usize) } #[tauri::command] @@ -2419,35 +2494,46 @@ pub async fn rename_assets( if trimmed.is_empty() { return Err("asset_name is empty".into()); } - let placeholders = vec!["?"; ids.len()].join(","); - // Snapshot before-state so undo can restore prior names. - let before_sql = format!( - "SELECT id, asset_name FROM assets WHERE id IN ({})", - placeholders - ); - let mut bq = sqlx::query_as::<_, (i64, String)>(&before_sql); - for id in &ids { - bq = bq.bind(id); - } - let prior = bq - .fetch_all(&state.pool) - .await - .map_err(|e| e.to_string())?; + // Chunked at 500 to stay under SQLite's 999 bind-param ceiling. let mut before_map = serde_json::Map::new(); - for (id, name) in &prior { - before_map.insert(id.to_string(), serde_json::json!({"asset_name": name})); + 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 upd_sql = format!( - "UPDATE assets SET asset_name = ?, modified = 1, modified_by = ?, \ - modified_at = datetime('now') WHERE id IN ({})", - placeholders - ); - let mut q = sqlx::query(&upd_sql).bind(&trimmed).bind(user_id()); - for id in &ids { - q = q.bind(id); + 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 = q.execute(&mut *tx).await.map_err(|e| e.to_string())?; + 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())?; @@ -2464,7 +2550,7 @@ pub async fn rename_assets( .await .map_err(|e| e.to_string())?; tx.commit().await.map_err(|e| e.to_string())?; - Ok(res.rows_affected() as usize) + Ok(res as usize) } #[derive(Debug, Serialize)] @@ -2696,18 +2782,25 @@ pub async fn restore_assets( if ids.is_empty() { return Ok(0); } - let placeholders = vec!["?"; ids.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", - placeholders - ); let mut tx = state.pool.begin().await.map_err(|e| e.to_string())?; - let mut q = sqlx::query(&upd_sql).bind(user_id()); - for id in &ids { - q = q.bind(id); + 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 res = q.execute(&mut *tx).await.map_err(|e| e.to_string())?; 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) @@ -2719,7 +2812,7 @@ pub async fn restore_assets( .await .map_err(|e| e.to_string())?; tx.commit().await.map_err(|e| e.to_string())?; - Ok(res.rows_affected() as usize) + Ok(total_affected as usize) } #[tauri::command] @@ -3153,6 +3246,325 @@ pub async fn export_osm_roads( 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"; + +#[tauri::command] +pub async fn generate_osm_near_assets( + radius_m: Option, + max_samples: Option, + path: String, + overpass_url: Option, + state: State<'_, AppState>, +) -> Result { + // Tighter defaults — only roads the assets are *on*, not nearby: + // radius_m = 60 m (capture buffer for GPS drift + side offset) + // keep_threshold = 30 m (post-filter: an asset must be within 30 m of + // the road centerline to count as "on" the road) + // min_votes = 2 (a road must collect at least this many asset + // hits to be kept; one stray asset isn't enough) + let radius = radius_m.unwrap_or(60.0); + let keep_threshold_m: f64 = 30.0; + let min_votes: usize = 2; + let cap = max_samples.unwrap_or(800).max(1); + // Pull all live asset centres. The point bbox columns are populated at + // import time so the SELECT is cheap. + let coords: Vec<(f64, f64)> = sqlx::query_as::<_, (f64, f64)>( + "SELECT 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())?; + if coords.is_empty() { + return Err("no live assets in DB to generate roads near".to_string()); + } + // Stride-sample for the around: query body. We use the *full* asset + // list further down for vote counting, so the sample is purely a + // query-size optimisation. + let stride = (coords.len().div_ceil(cap)).max(1); + let sampled: Vec<&(f64, f64)> = coords.iter().step_by(stride).collect(); + // Build a `(lat,lng,lat,lng,...)` list. Overpass `around:r,...` returns + // ways with any node within `r` metres of any of the given points. + let mut latlng = String::with_capacity(sampled.len() * 22); + for (i, (lat, lng)) in sampled.iter().enumerate() { + if i > 0 { + latlng.push(','); + } + latlng.push_str(&format!("{},{}", lat, lng)); + } + let query = format!( + "[out:json][timeout:180];\ + (way[\"highway\"~\"{classes}\"](around:{r},{pts}););\ + out body geom;", + classes = OSM_HIGHWAY_CLASSES, + r = radius as i64, + pts = latlng, + ); + 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 = 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 only roads where >= min_votes assets sit within + // keep_threshold_m of the centerline. The around: query alone returns + // every road within `radius` m of any asset — including parallel/cross + // streets that just happen to be close. The vote step picks out the + // road(s) the assets actually follow. + let kept = filter_features_by_asset_votes( + features, + &coords, + keep_threshold_m, + min_votes, + ); + + 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(count) +} + +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() +} + +fn filter_features_by_asset_votes( + features: Vec, + asset_coords: &[(f64, f64)], + threshold_m: f64, + min_votes: usize, +) -> Vec { + let mut out: Vec = 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; + } + // Road bbox + buffer in degrees (so we can skip far-away assets cheaply). + let mut mn_lng = f64::INFINITY; + let mut mx_lng = f64::NEG_INFINITY; + let mut mn_lat = f64::INFINITY; + let mut mx_lat = f64::NEG_INFINITY; + for &[lng, lat] in &pts { + if lng < mn_lng { mn_lng = lng; } + if lng > mx_lng { mx_lng = lng; } + if lat < mn_lat { mn_lat = lat; } + if lat > mx_lat { mx_lat = lat; } + } + let lat0 = ((mn_lat + mx_lat) * 0.5).to_radians(); + let m_per_lng = (111_320.0_f64 * lat0.cos()).max(1.0); + let buf_lat = threshold_m / 111_320.0; + let buf_lng = threshold_m / m_per_lng; + + let mut votes: usize = 0; + for &(a_lat, a_lng) in asset_coords { + if a_lng < mn_lng - buf_lng + || a_lng > mx_lng + buf_lng + || a_lat < mn_lat - buf_lat + || a_lat > mx_lat + buf_lat + { + continue; + } + let mut hit = false; + for w in pts.windows(2) { + let d = point_segment_distance_m( + a_lng, a_lat, + w[0][0], w[0][1], + w[1][0], w[1][1], + ); + if d < threshold_m { + hit = true; + break; + } + } + if hit { + votes += 1; + if votes >= min_votes { + // Early exit — we already have enough votes; no need to + // count exactly how many more. + break; + } + } + } + if votes >= min_votes { + out.push(feat); + } + } + out +} + +#[tauri::command] +pub async fn assets_bbox( + state: State<'_, AppState>, +) -> Result, 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, + Option, + Option, + Option, + )> = 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, @@ -3173,16 +3585,32 @@ pub async fn generate_osm_for_bbox( ); 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=`. + // 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) - .body(query) + .header("Accept", "application/json") + .form(&[("data", &query)]) .send() .await .map_err(|e| format!("Overpass request failed: {}", e))?; if !resp.status().is_success() { - return Err(format!("Overpass HTTP {}", resp.status())); + 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))?; @@ -3871,9 +4299,13 @@ pub async fn snap_to_road( .collect::>(), ) .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 ('move', 'single', ?, ?, ?, ?, datetime('now'))", + VALUES ('snap_single', 'single', ?, ?, ?, ?, datetime('now'))", ) .bind(format!("[{}]", asset_id)) .bind(user_id()) @@ -4352,6 +4784,79 @@ pub async fn redo_action(action_id: i64, state: State<'_, AppState>) -> Result<( .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 = 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 = serde_json::from_str(&row.asset_ids).map_err(|e| e.to_string())?; @@ -4464,6 +4969,63 @@ pub async fn redo_action(action_id: i64, state: State<'_, AppState>) -> Result<( 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::(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') diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 318246d..18f6cfb 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -187,7 +187,9 @@ async fn apply_schema(pool: &SqlitePool) -> Result<()> { min_lat REAL NOT NULL, max_lat REAL NOT NULL, min_lng REAL NOT NULL, - max_lng REAL NOT NULL + max_lng REAL NOT NULL, + oneway INTEGER NOT NULL DEFAULT 0, + highway TEXT )", ) .execute(pool) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a9863da..9ac0e8e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,7 +3,7 @@ mod db; mod scope; use commands::{ - delete_asset, delete_assets_by_video, get_assets_in_bbox, get_osm_roads, + assets_bbox, delete_asset, delete_assets_by_video, get_assets_in_bbox, get_osm_roads, get_scope_polygons, import_fixed_assets, import_kml_scope, import_osm_geojson, import_range_assets, list_assets, read_video_polylines_json, list_recent_actions, merge_polylines, reset_assets_to_original, read_metadata_polyline, redo_action, reset_database, snap_assets_in_bbox, @@ -11,7 +11,8 @@ use commands::{ clear_osm_roads, clear_scope_polygons, data_source_counts, change_assets_side, clear_link, clear_manual_scope, create_osm_road, create_user, delete_road_vertices, set_link, simplify_road, delete_roads_by_highway, export_osm_roads, find_duplicate_clusters, - flip_road_direction, generate_osm_for_bbox, list_road_classes, merge_roads_by_name, + flip_road_direction, generate_osm_for_bbox, generate_osm_near_assets, + list_road_classes, merge_roads_by_name, delete_assets_bulk, delete_osm_road, delete_user, export_assets, list_asset_names, list_classes, list_users, list_video_names, rename_assets, restore_assets, set_assets_by_video_in_scope, @@ -88,6 +89,8 @@ pub fn run() { merge_roads_by_name, export_osm_roads, generate_osm_for_bbox, + generate_osm_near_assets, + assets_bbox, flip_road_direction, change_assets_side, auto_link_in_polygon, diff --git a/src/App.tsx b/src/App.tsx index e15b324..311496d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -149,6 +149,39 @@ function FpsCounter() { ); } +// Lightweight indicator while the Overpass fetch is in flight. Single line, +// no animation, no fixed positioning — just a small chip in the corner so +// the user knows the click is doing something. +function OsmFetchIndicator() { + const [elapsed, setElapsed] = useState(0); + useEffect(() => { + const start = performance.now(); + const id = window.setInterval(() => { + setElapsed(Math.floor((performance.now() - start) / 1000)); + }, 500); + return () => window.clearInterval(id); + }, []); + return ( +
+ Fetching OSM… {elapsed}s +
+ ); +} + function AppShell({ currentUser, onLogout, @@ -238,6 +271,7 @@ function AppShell({ if (typeof window === "undefined") return false; return window.localStorage.getItem(PERF_MODE_KEY) === "1"; }); + const [osmFetchActive, setOsmFetchActive] = useState(false); const [filters, setFilters] = useState({ videos: new Set(), sides: new Set(), @@ -1155,7 +1189,11 @@ function AppShell({ refreshScopePolygons().catch((e) => setStatus(`Scope load failed: ${e}`), ); - refreshOsmRoads().catch(() => {}); + // Surface OSM load failures — silent catch made startup look fine + // even when the roads table couldn't be read. + refreshOsmRoads().catch((e) => + setStatus(`OSM roads load failed: ${e}`), + ); refreshActions().catch(() => {}); refreshAssetNames().catch(() => {}); refreshVideoNames().catch(() => {}); @@ -1234,6 +1272,7 @@ function AppShell({ coords: drawRoadCoords, }); await refreshOsmRoads(); + await refreshDataCounts(); setDrawRoadMode(false); setDrawRoadCoords([]); setEditingRoadId(newId); @@ -1252,6 +1291,7 @@ function AppShell({ try { await invoke("delete_osm_road", { id: editingRoadId }); await refreshOsmRoads(); + await refreshDataCounts(); setEditingRoadId(null); setStatus(`Deleted road #${editingRoadId}.`); } catch (e) { @@ -1304,13 +1344,26 @@ function AppShell({ "tertiary_link", ]; } - setStatus("Importing OSM roads…"); + setStatus(`Importing OSM roads from ${picked.split(/[\\/]/).pop()}…`); const count = await invoke("import_osm_geojson", args); const { bounds } = await refreshOsmRoads(); + // Keep the sidebar Loaded-data card in sync. Without this the OSM + // count stayed at its last value, which made imports look like they + // hadn't done anything. + await refreshDataCounts(); // Auto-recentre the map on the imported road bbox. Without this, if // the user's current viewport is far from the imported area, the // roads render off-screen and the import looks silently broken. - if (bounds) { + if (count === 0) { + // Explicit message — silent "Loaded 0" looked like the import + // did nothing. Common cause: the major-only filter removed every + // road class in the file, or the GeoJSON had no LineStrings. + setStatus( + majorOnly + ? `0 road segments matched the major-only filter. Re-import and click Cancel on the filter prompt to import all classes.` + : `0 road segments imported. Check that the GeoJSON has LineString features.`, + ); + } else if (bounds) { const center: [number, number] = [ (bounds.minLng + bounds.maxLng) / 2, (bounds.minLat + bounds.maxLat) / 2, @@ -1356,33 +1409,54 @@ function AppShell({ async function handleGenerateOsm() { if (busy) return; - if (!boundsRef.current) { - setStatus("Pan/zoom the map first."); - return; - } const folder = await ensureOsmFolder(); if (!folder) return; - const b = boundsRef.current; const sep = folder.endsWith("/") || folder.endsWith("\\") ? "" : "/"; const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); - const path = `${folder}${sep}osm_${b.south.toFixed(4)}_${b.west.toFixed(4)}_${b.north.toFixed(4)}_${b.east.toFixed(4)}_${ts}.geojson`; + const hasAssets = dataCounts.fixed_assets + dataCounts.range_assets > 0; setBusy(true); - setStatus("Fetching OSM from Overpass…"); + setOsmFetchActive(true); try { - const n = await invoke("generate_osm_for_bbox", { - minLat: b.south, - minLng: b.west, - maxLat: b.north, - maxLng: b.east, - path, - }); - setStatus( - `Generated ${n} OSM way(s) → ${path}. Use Import → OSM roads to load.`, - ); + if (hasAssets) { + // Tight per-asset filter: fetch a small candidate set with + // around:60m, then keep only roads where ≥2 assets sit within 30 m + // of the centerline. That way the output is the road(s) the + // assets are actually on, not every street that happens to be + // within bbox or radius. + setStatus("Fetching OSM near assets from Overpass…"); + const path = `${folder}${sep}osm_on_asset_roads_${ts}.geojson`; + const n = await invoke("generate_osm_near_assets", { + radiusM: 60, + maxSamples: 800, + path, + }); + setStatus( + `Generated ${n} road(s) carrying assets → ${path}. Use Import → OSM roads to load.`, + ); + } else if (boundsRef.current) { + const b = boundsRef.current; + const path = `${folder}${sep}osm_${b.south.toFixed(4)}_${b.west.toFixed(4)}_${b.north.toFixed(4)}_${b.east.toFixed(4)}_${ts}.geojson`; + setStatus("Fetching OSM for visible viewport from Overpass…"); + const n = await invoke("generate_osm_for_bbox", { + minLat: b.south, + minLng: b.west, + maxLat: b.north, + maxLng: b.east, + path, + }); + setStatus( + `Generated ${n} OSM way(s) from viewport → ${path}. Use Import → OSM roads to load.`, + ); + } else { + setStatus( + "No assets imported and no map viewport set. Import assets or pan/zoom the map first.", + ); + } } catch (e) { setStatus(`Generate OSM failed: ${e}`); } finally { + setOsmFetchActive(false); setBusy(false); } } @@ -1422,6 +1496,8 @@ function AppShell({ } } await refreshOsmRoads(); + // Sidebar count would otherwise show pre-prune total. + await refreshDataCounts(); setOsmToolsOpen(false); setStatus( summary.length > 0 ? `OSM tools: ${summary.join("; ")}.` : "Nothing selected.", @@ -1688,7 +1764,7 @@ function AppShell({ >; for (const e of merged) { if (e.kind !== "target") continue; - // Find bracketing anchors by rank. + // Find bracketing anchors using row_id ordering (rank in the merged list). let left: (typeof anchorByRank)[number] | null = null; let right: (typeof anchorByRank)[number] | null = null; for (const a of anchorByRank) { @@ -1701,8 +1777,35 @@ function AppShell({ let dlat: number; let dlng: number; if (left && right) { - const span = right.rank - left.rank; - const tt = span > 0 ? (e.rank - left.rank) / span : 0; + // Interpolate by **geographic position** of the target projected + // onto the line segment between the bracketing anchors' original + // positions — i.e. along the road direction. This replaces the + // previous rank-based tt, which over-corrected clustered targets + // toward the next anchor regardless of how close they actually + // were geographically. Latitude is scaled by cos(centre_lat) so + // the projection isn't distorted by lat/lng's non-isotropic + // metric at the dataset's latitude (Hyderabad ≈ 17°: 1° lng is + // ~95 % of 1° lat there). + const lx = left.anchor.origLng; + const ly = left.anchor.origLat; + const rx = right.anchor.origLng; + const ry = right.anchor.origLat; + const lat0 = ((ly + ry) / 2) * (Math.PI / 180); + const cosLat = Math.cos(lat0); + const tx = (e.asset.lng as number) - lx; + const ty = (e.asset.lat as number) - ly; + const sx = rx - lx; + const sy = ry - ly; + // Scale lng deltas by cosLat so the dot product is in metres²-equiv. + const seg2 = sx * sx * cosLat * cosLat + sy * sy; + let tt: number; + if (seg2 > 0) { + const dot = tx * sx * cosLat * cosLat + ty * sy; + tt = Math.max(0, Math.min(1, dot / seg2)); + } else { + // Degenerate: anchors coincide. Fall back to left's offset. + tt = 0; + } dlat = left.anchor.dlat + (right.anchor.dlat - left.anchor.dlat) * tt; dlng = left.anchor.dlng + (right.anchor.dlng - left.anchor.dlng) * tt; } else { @@ -2183,6 +2286,7 @@ function AppShell({ return (
{perfMode && } + {osmFetchActive && }
- Generate (visible bbox) → + Generate roads KML →