diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index ff5022d..d600b92 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -21,6 +21,8 @@ struct FixedAssetInput { deleted: bool, #[serde(default)] side: Option, + #[serde(default)] + lane_side: Option, } #[derive(Debug, Deserialize)] @@ -85,6 +87,8 @@ struct RangeAssetInput { deleted: bool, #[serde(default)] side: Option, + #[serde(default)] + lane_side: Option, } fn opt_num_or_str_to_f64(v: &Option) -> Option { @@ -204,6 +208,7 @@ pub struct Asset { pub merged_into: Option, pub in_scope: Option, pub side: Option, + pub lane_side: Option, pub link_pair_id: Option, pub link_locked: Option, } @@ -216,6 +221,17 @@ fn normalize_side(s: &str) -> Option { } } +// Lane side stays as canonical "LHS" / "RHS" — these label the carriageway +// itself (from the import-time site_id pair), distinct from the per-asset +// left/right-of-vehicle `side`. +fn normalize_lane_side(s: &str) -> Option { + match s.trim().to_uppercase().as_str() { + "LHS" | "LEFT" | "L" => Some("LHS".to_string()), + "RHS" | "RIGHT" | "R" => Some("RHS".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 @@ -226,13 +242,14 @@ async fn insert_fixed_row( ) -> Result<(), String> { let (lat, lng) = input.coord.to_lat_lng()?; let side = input.side.as_deref().and_then(normalize_side); + let lane_side = input.lane_side.as_deref().and_then(normalize_lane_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', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + image_path, deleted, side, lane_side + ) VALUES (?, 'fixed', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(row_id) DO UPDATE SET asset_type = excluded.asset_type, asset_name = excluded.asset_name, @@ -256,7 +273,8 @@ async fn insert_fixed_row( 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 + side = CASE WHEN assets.modified = 0 THEN excluded.side ELSE assets.side END, + lane_side = CASE WHEN assets.modified = 0 THEN excluded.lane_side ELSE assets.lane_side END "#, ) .bind(&input.row_id) @@ -271,6 +289,7 @@ async fn insert_fixed_row( .bind(&input.image_path) .bind(input.deleted as i64) .bind(&side) + .bind(&lane_side) .execute(&mut **tx) .await .map_err(|e| e.to_string())?; @@ -296,6 +315,7 @@ async fn insert_range_row( 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); + let lane_side = input.lane_side.as_deref().and_then(normalize_lane_side); sqlx::query( r#" @@ -303,8 +323,8 @@ async fn insert_range_row( 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', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + image_path1, image_path2, image_path3, deleted, side, lane_side + ) VALUES (?, 'range', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(row_id) DO UPDATE SET asset_type = excluded.asset_type, asset_name = excluded.asset_name, @@ -328,7 +348,8 @@ async fn insert_range_row( -- 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 + side = CASE WHEN assets.modified = 0 THEN excluded.side ELSE assets.side END, + lane_side = CASE WHEN assets.modified = 0 THEN excluded.lane_side ELSE assets.lane_side END "#, ) .bind(&input.row_id) @@ -349,6 +370,7 @@ async fn insert_range_row( .bind(&input.image_path3) .bind(input.deleted as i64) .bind(&side) + .bind(&lane_side) .execute(&mut **tx) .await .map_err(|e| e.to_string())?; @@ -755,7 +777,7 @@ pub async fn update_asset_position( "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 + deleted, modified, merged_into, in_scope, side, lane_side, link_pair_id, link_locked FROM assets WHERE id = ?", ) .bind(id) @@ -875,7 +897,7 @@ pub async fn bulk_translate( "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 + deleted, modified, merged_into, in_scope, side, lane_side, link_pair_id, link_locked FROM assets WHERE id IN ({})", placeholders ); @@ -1004,7 +1026,7 @@ pub async fn reset_assets_to_original( "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 + deleted, modified, merged_into, in_scope, side, lane_side, link_pair_id, link_locked FROM assets WHERE id IN ({})", placeholders ); @@ -1228,7 +1250,7 @@ pub async fn merge_polylines( "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 + deleted, modified, merged_into, in_scope, side, lane_side, link_pair_id, link_locked FROM assets WHERE id = ?", ) .bind(primary_id) @@ -1239,7 +1261,7 @@ pub async fn merge_polylines( "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 + deleted, modified, merged_into, in_scope, side, lane_side, link_pair_id, link_locked FROM assets WHERE id = ?", ) .bind(secondary_id) @@ -1992,13 +2014,28 @@ pub async fn export_assets( "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 + deleted, modified, merged_into, in_scope, side, lane_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())?; + // Resolve link partner ids → row_ids. The raw `link_pair_id` is an + // internal autoincrement that changes on every re-import, so a CSV + // consumer can't tell who linked to whom by looking at the number. + // Look up the partner's row_id once and surface it as a parallel column + // ("link_pair_row_id") in every format. Falls back to None when the + // partner row is deleted (and thus not in `assets`) — flagged so the + // user can see the dangling link. + let row_id_by_id: std::collections::HashMap = assets + .iter() + .map(|a| (a.id, a.row_id.clone())) + .collect(); + let partner_row_id = |pid: Option| -> Option { + pid.and_then(|p| row_id_by_id.get(&p).cloned()) + }; + // Pre-fetch all bboxes once so every format branch that wants to surface // them can do so without re-querying. Grouped by asset_id → slot. let bbox_rows_all: Vec<(i64, String, f64, f64, f64, f64)> = sqlx::query_as( @@ -2038,6 +2075,7 @@ pub async fn export_assets( "image_path2": a.image_path2, "image_path3": a.image_path3, "link_pair_id": a.link_pair_id, + "link_pair_row_id": partner_row_id(a.link_pair_id), "link_locked": a.link_locked, }); // Inline bbox into the GeoJSON feature's properties using the @@ -2188,6 +2226,9 @@ pub async fn export_assets( "coord": [la, ln], "deleted": a.deleted != 0, "side": a.side, + "lane_side": a.lane_side, + "link_pair_row_id": partner_row_id(a.link_pair_id), + "link_locked": a.link_locked, }); if let Some(slots) = bbox_by_asset.get(&a.id) { if let Some(bb) = slots.get("fixed") { @@ -2215,6 +2256,9 @@ pub async fn export_assets( "image_path3": a.image_path3, "deleted": a.deleted != 0, "side": a.side, + "lane_side": a.lane_side, + "link_pair_row_id": partner_row_id(a.link_pair_id), + "link_locked": a.link_locked, }); if let Some(slots) = bbox_by_asset.get(&a.id) { if let Some(bb) = slots.get("start") { @@ -2244,7 +2288,7 @@ pub async fn export_assets( // Each row populates only the slots its asset_type uses (fixed // assets fill the unprefixed `bbox_*`, range assets fill // `start_/mid_/end_bbox_*`); unused slots stay empty. - 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,bbox_x1,bbox_y1,bbox_x2,bbox_y2,start_bbox_x1,start_bbox_y1,start_bbox_x2,start_bbox_y2,mid_bbox_x1,mid_bbox_y1,mid_bbox_x2,mid_bbox_y2,end_bbox_x1,end_bbox_y1,end_bbox_x2,end_bbox_y2\n"); + s.push_str("id,row_id,asset_type,asset_name,video_name,side,lane_side,in_scope,deleted,modified,lat,lng,start_lat,start_lng,end_lat,end_lng,link_pair_id,link_pair_row_id,link_locked,image_path,image_path1,image_path2,image_path3,bbox_x1,bbox_y1,bbox_x2,bbox_y2,start_bbox_x1,start_bbox_y1,start_bbox_x2,start_bbox_y2,mid_bbox_x1,mid_bbox_y1,mid_bbox_x2,mid_bbox_y2,end_bbox_x1,end_bbox_y1,end_bbox_x2,end_bbox_y2\n"); let csv_q = |v: Option<&str>| -> String { let s = v.unwrap_or(""); if s.contains(',') || s.contains('"') || s.contains('\n') { @@ -2267,14 +2311,16 @@ pub async fn export_assets( let bb_start = slots.and_then(|m| m.get("start")); let bb_mid = slots.and_then(|m| m.get("mid")); let bb_end = slots.and_then(|m| m.get("end")); + let partner = partner_row_id(a.link_pair_id); s.push_str(&format!( - "{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}\n", + "{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}\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()), + csv_q(a.lane_side.as_deref()), i(a.in_scope), a.deleted, a.modified, @@ -2285,6 +2331,7 @@ pub async fn export_assets( f(a.end_lat), f(a.end_lng), i(a.link_pair_id), + csv_q(partner.as_deref()), i(a.link_locked), csv_q(a.image_path.as_deref()), csv_q(a.image_path1.as_deref()), @@ -2975,7 +3022,7 @@ pub async fn find_duplicate_clusters( "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 + deleted, modified, merged_into, in_scope, side, lane_side, link_pair_id, link_locked FROM assets WHERE deleted = 0", ) .fetch_all(&state.pool) @@ -5770,7 +5817,7 @@ pub async fn snap_to_road( "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 + deleted, modified, merged_into, in_scope, side, lane_side, link_pair_id, link_locked FROM assets WHERE id = ?", ) .bind(asset_id) @@ -6415,7 +6462,7 @@ pub async fn redo_action(action_id: i64, state: State<'_, AppState>) -> Result<( "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 + deleted, modified, merged_into, in_scope, side, lane_side, link_pair_id, link_locked FROM assets WHERE id = ?", ) .bind(id) @@ -6493,7 +6540,7 @@ pub async fn redo_action(action_id: i64, state: State<'_, AppState>) -> Result<( "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 + deleted, modified, merged_into, in_scope, side, lane_side, link_pair_id, link_locked FROM assets WHERE id = ?", ) .bind(id) @@ -6746,7 +6793,7 @@ pub async fn redo_action(action_id: i64, state: State<'_, AppState>) -> Result<( "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 + deleted, modified, merged_into, in_scope, side, lane_side, link_pair_id, link_locked FROM assets WHERE id = ?", ) .bind(id) @@ -6855,7 +6902,7 @@ pub async fn list_assets(state: State<'_, AppState>) -> Result, Strin "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 + deleted, modified, merged_into, in_scope, side, lane_side, link_pair_id, link_locked FROM assets WHERE deleted = 0", ) .fetch_all(&state.pool) diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 47fbf0c..f49f3cd 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -87,6 +87,12 @@ async fn apply_schema(pool: &SqlitePool) -> Result<()> { ) .execute(pool) .await; + // Lane side: "LHS" / "RHS" derived externally (e.g. lower vs higher + // site_id in the import pipeline). Independent of `side` which is the + // asset's left/right position relative to the vehicle. + let _ = sqlx::query("ALTER TABLE assets ADD COLUMN lane_side TEXT") + .execute(pool) + .await; // Original positions captured at import time. Never overwritten by edits or // re-imports; used to support "Reset to original" (single + bulk). for col in [ diff --git a/src/App.tsx b/src/App.tsx index d2606a9..66961fd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,6 +29,9 @@ const POPUP_ASPECT_VAL: Record = { type Filters = { videos: Set; // empty = all sides: Set; // empty = all + // Carriageway/lane the asset's site_id belongs to (LHS / RHS). Stays + // distinct from `sides`, which is left/right-of-vehicle. + lanes: Set; types: Set<"fixed" | "range">; // empty = all names: Set; // empty = all (asset_name) }; @@ -402,6 +405,7 @@ function AppShell({ const [filters, setFilters] = useState({ videos: new Set(), sides: new Set(), + lanes: new Set(), types: new Set(), names: new Set(), }); @@ -731,15 +735,18 @@ function AppShell({ const counts = useMemo(() => { const byVideo = new Map(); const bySide = new Map(); + const byLane = new Map(); const byType = new Map(); for (const a of assets) { const vn = displayVideo(a.video_name); byVideo.set(vn, (byVideo.get(vn) ?? 0) + 1); const s = a.side ?? "(none)"; bySide.set(s, (bySide.get(s) ?? 0) + 1); + const l = a.lane_side ?? "(none)"; + byLane.set(l, (byLane.get(l) ?? 0) + 1); byType.set(a.asset_type, (byType.get(a.asset_type) ?? 0) + 1); } - return { byVideo, bySide, byType }; + return { byVideo, bySide, byLane, byType }; }, [assets]); const filteredAssets = useMemo(() => { @@ -750,6 +757,10 @@ function AppShell({ const s = a.side ?? "(none)"; if (!filters.sides.has(s)) return false; } + if (filters.lanes.size) { + const l = a.lane_side ?? "(none)"; + if (!filters.lanes.has(l)) return false; + } if (filters.types.size && !filters.types.has(a.asset_type)) return false; if (filters.names.size && !filters.names.has(a.asset_name)) return false; // Video filter: explicit list. If allVideoNames is loaded and the asset's @@ -781,11 +792,15 @@ function AppShell({ const s = a.side ?? "(none)"; if (!filters.sides.has(s)) continue; } + if (filters.lanes.size) { + const l = a.lane_side ?? "(none)"; + if (!filters.lanes.has(l)) continue; + } if (filters.types.size && !filters.types.has(a.asset_type)) continue; m.set(a.asset_name, (m.get(a.asset_name) ?? 0) + 1); } return m; - }, [assets, filters.videos, filters.sides, filters.types]); + }, [assets, filters.videos, filters.sides, filters.lanes, filters.types]); function toggleSet(set: Set, value: T): Set { const next = new Set(set); @@ -798,6 +813,7 @@ function AppShell({ setFilters({ videos: new Set(), sides: new Set(), + lanes: new Set(), types: new Set(), names: new Set(), }); @@ -4250,6 +4266,7 @@ function AppShell({ disabled={ filters.videos.size + filters.sides.size + + filters.lanes.size + filters.types.size + filters.names.size === 0 @@ -4358,6 +4375,28 @@ function AppShell({ ))} +

+ Lane +

+ {Array.from(counts.byLane.entries()) + .sort() + .map(([lane, n]) => ( + + ))} +

setVideosExpanded((v) => !v)} diff --git a/src/types.ts b/src/types.ts index b15fcd7..8aed161 100644 --- a/src/types.ts +++ b/src/types.ts @@ -38,6 +38,7 @@ export type Asset = { merged_into: number | null; in_scope: number | null; side: string | null; + lane_side: string | null; link_pair_id: number | null; link_locked: number | null; };