kml first commit

This commit is contained in:
2026-04-27 12:48:37 +05:30
commit ac72da88f2
48 changed files with 24957 additions and 0 deletions

7
src-tauri/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas

6481
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

32
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,32 @@
[package]
name = "kml_map_tool"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "kml_map_tool_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["protocol-asset"] }
tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite", "macros"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs"] }
anyhow = "1"
kml = "0.13"
geo = "0.33"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }

3
src-tauri/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default",
"dialog:default"
]
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

4343
src-tauri/src/commands.rs Normal file

File diff suppressed because it is too large Load Diff

247
src-tauri/src/db.rs Normal file
View File

@@ -0,0 +1,247 @@
use anyhow::Result;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use sqlx::SqlitePool;
use std::path::Path;
use std::str::FromStr;
pub async fn init_pool(db_path: &Path) -> Result<SqlitePool> {
if let Some(parent) = db_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let url = format!("sqlite://{}", db_path.display());
let opts = SqliteConnectOptions::from_str(&url)?
.create_if_missing(true)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.synchronous(sqlx::sqlite::SqliteSynchronous::Normal);
let pool = SqlitePoolOptions::new()
.max_connections(4)
.connect_with(opts)
.await?;
apply_schema(&pool).await?;
Ok(pool)
}
async fn apply_schema(pool: &SqlitePool) -> Result<()> {
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS assets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
row_id TEXT NOT NULL UNIQUE,
asset_type TEXT NOT NULL CHECK (asset_type IN ('fixed','range')),
asset_name TEXT NOT NULL,
video_name TEXT NOT NULL,
lat REAL,
lng REAL,
start_lat REAL,
start_lng REAL,
end_lat REAL,
end_lng REAL,
min_lat REAL NOT NULL,
max_lat REAL NOT NULL,
min_lng REAL NOT NULL,
max_lng REAL NOT NULL,
image_path TEXT,
image_path1 TEXT,
image_path2 TEXT,
image_path3 TEXT,
deleted INTEGER NOT NULL DEFAULT 0,
modified INTEGER NOT NULL DEFAULT 0,
modified_by TEXT,
modified_at TEXT,
merged_into INTEGER REFERENCES assets(id),
in_scope INTEGER
)
"#,
)
.execute(pool)
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_assets_video ON assets(video_name)")
.execute(pool)
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_assets_type ON assets(asset_type)")
.execute(pool)
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_assets_name ON assets(asset_name)")
.execute(pool)
.await?;
// Migration: side detected from image_path (LHS/RHS) at import time.
let _ = sqlx::query("ALTER TABLE assets ADD COLUMN side TEXT")
.execute(pool)
.await;
// Manual scope override: NULL = derived from scope polygons (default),
// 0 = forced out-of-scope, 1 = forced in-scope. Set via lasso bulk edits.
let _ = sqlx::query("ALTER TABLE assets ADD COLUMN manual_scope INTEGER")
.execute(pool)
.await;
// Cross-side / cross-video pair links: link_pair_id is the partner asset's id.
// link_locked = 1 marks a manual link that survives auto-match re-runs.
let _ = sqlx::query("ALTER TABLE assets ADD COLUMN link_pair_id INTEGER")
.execute(pool)
.await;
let _ = sqlx::query(
"ALTER TABLE assets ADD COLUMN link_locked INTEGER NOT NULL DEFAULT 0",
)
.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 [
"orig_lat", "orig_lng",
"orig_start_lat", "orig_start_lng",
"orig_end_lat", "orig_end_lng",
] {
let _ = sqlx::query(&format!("ALTER TABLE assets ADD COLUMN {} REAL", col))
.execute(pool)
.await;
}
let _ = sqlx::query("ALTER TABLE roads ADD COLUMN oneway INTEGER NOT NULL DEFAULT 0")
.execute(pool)
.await;
let _ = sqlx::query("ALTER TABLE roads ADD COLUMN highway TEXT")
.execute(pool)
.await;
sqlx::query(
"CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE VIRTUAL TABLE IF NOT EXISTS assets_rtree USING rtree(
id, min_lat, max_lat, min_lng, max_lng
)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE TRIGGER IF NOT EXISTS assets_ai AFTER INSERT ON assets
BEGIN
INSERT INTO assets_rtree(id, min_lat, max_lat, min_lng, max_lng)
VALUES (NEW.id, NEW.min_lat, NEW.max_lat, NEW.min_lng, NEW.max_lng);
END",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE TRIGGER IF NOT EXISTS assets_au AFTER UPDATE ON assets
BEGIN
UPDATE assets_rtree
SET min_lat = NEW.min_lat, max_lat = NEW.max_lat,
min_lng = NEW.min_lng, max_lng = NEW.max_lng
WHERE id = OLD.id;
END",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE TRIGGER IF NOT EXISTS assets_ad AFTER DELETE ON assets
BEGIN
DELETE FROM assets_rtree WHERE id = OLD.id;
END",
)
.execute(pool)
.await?;
sqlx::query(
"INSERT INTO assets_rtree(id, min_lat, max_lat, min_lng, max_lng)
SELECT id, min_lat, max_lat, min_lng, max_lng FROM assets
WHERE id NOT IN (SELECT id FROM assets_rtree)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS scope_polygons (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
geojson TEXT NOT NULL,
geom_type TEXT NOT NULL DEFAULT 'polygon'
)",
)
.execute(pool)
.await?;
// Migration for DBs predating geom_type column.
let _ = sqlx::query(
"ALTER TABLE scope_polygons ADD COLUMN geom_type TEXT NOT NULL DEFAULT 'polygon'",
)
.execute(pool)
.await;
sqlx::query(
"CREATE TABLE IF NOT EXISTS roads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
geojson TEXT NOT NULL,
min_lat REAL NOT NULL,
max_lat REAL NOT NULL,
min_lng REAL NOT NULL,
max_lng REAL NOT NULL
)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE VIRTUAL TABLE IF NOT EXISTS roads_rtree USING rtree(
id, min_lat, max_lat, min_lng, max_lng
)",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE TRIGGER IF NOT EXISTS roads_ai AFTER INSERT ON roads
BEGIN
INSERT INTO roads_rtree(id, min_lat, max_lat, min_lng, max_lng)
VALUES (NEW.id, NEW.min_lat, NEW.max_lat, NEW.min_lng, NEW.max_lng);
END",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE TRIGGER IF NOT EXISTS roads_ad AFTER DELETE ON roads
BEGIN
DELETE FROM roads_rtree WHERE id = OLD.id;
END",
)
.execute(pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS action_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action_type TEXT NOT NULL,
scope TEXT NOT NULL,
asset_ids TEXT NOT NULL,
user_id TEXT NOT NULL,
before TEXT,
after TEXT,
ts TEXT NOT NULL,
undone INTEGER NOT NULL DEFAULT 0
)",
)
.execute(pool)
.await?;
// Migration for DBs created before `after` column existed.
let _ = sqlx::query("ALTER TABLE action_history ADD COLUMN after TEXT")
.execute(pool)
.await;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_audit_ts ON action_history(ts DESC)")
.execute(pool)
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_audit_undone ON action_history(undone)")
.execute(pool)
.await?;
Ok(())
}

96
src-tauri/src/lib.rs Normal file
View File

@@ -0,0 +1,96 @@
mod commands;
mod db;
mod scope;
use commands::{
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, list_recent_actions, merge_polylines, reset_assets_to_original,
read_metadata_polyline, redo_action, reset_database, snap_assets_in_bbox,
add_class, auto_link_in_polygon, bulk_translate, change_assets_side, clear_link, clear_manual_scope,
create_osm_road, create_user, set_link,
delete_roads_by_highway, export_osm_roads, find_duplicate_clusters,
flip_road_direction, generate_osm_for_bbox, 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,
set_assets_in_scope, set_current_user, snap_to_road,
undo_action, update_asset_position, update_osm_road, AppState,
};
use tauri::Manager;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
let app_data = app
.path()
.app_data_dir()
.expect("could not resolve app data dir");
let db_path = app_data.join("markers.db");
let handle = app.handle().clone();
tauri::async_runtime::block_on(async move {
let pool = db::init_pool(&db_path)
.await
.expect("failed to initialize SQLite pool");
handle.manage(AppState { pool });
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
import_fixed_assets,
import_range_assets,
list_assets,
get_assets_in_bbox,
update_asset_position,
import_kml_scope,
get_scope_polygons,
delete_asset,
delete_assets_by_video,
undo_action,
list_recent_actions,
merge_polylines, reset_assets_to_original,
reset_database,
redo_action,
import_osm_geojson,
snap_to_road,
get_osm_roads,
read_metadata_polyline,
snap_assets_in_bbox,
update_osm_road,
create_osm_road,
delete_osm_road,
list_asset_names,
export_assets,
set_assets_in_scope,
set_assets_by_video_in_scope,
clear_manual_scope,
delete_assets_bulk,
list_video_names,
list_classes,
add_class,
rename_assets,
restore_assets,
list_users,
create_user,
delete_user,
set_current_user,
find_duplicate_clusters,
delete_roads_by_highway,
list_road_classes,
merge_roads_by_name,
export_osm_roads,
generate_osm_for_bbox,
flip_road_direction,
change_assets_side,
auto_link_in_polygon,
bulk_translate,
set_link,
clear_link
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

6
src-tauri/src/main.rs Normal file
View File

@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
kml_map_tool_lib::run()
}

270
src-tauri/src/scope.rs Normal file
View File

@@ -0,0 +1,270 @@
use anyhow::Result;
use geo::algorithm::Distance;
use geo::{ClosestPoint, Closest, Contains, Coord, Geodesic, LineString, Point, Polygon as GeoPolygon};
use kml::types::{Geometry as KmlGeom, Kml, Placemark};
use sqlx::SqlitePool;
use std::path::Path;
/// Distance threshold for "near a scope LineString" → in_scope = 1.
const LINE_PROXIMITY_M: f64 = 30.0;
#[derive(Clone)]
pub enum ScopeGeom {
Polygon(GeoPolygon<f64>),
Line(LineString<f64>),
}
pub struct ParsedFeature {
pub name: Option<String>,
pub geom: ScopeGeom,
pub geojson: String,
pub geom_type: &'static str,
}
pub async fn parse_kml(path: &Path) -> Result<Vec<ParsedFeature>> {
let s = tokio::fs::read_to_string(path).await?;
let kml: Kml = s.parse()?;
let mut out = vec![];
walk(&kml, None, &mut out);
Ok(out)
}
fn walk(kml: &Kml, parent_name: Option<&str>, out: &mut Vec<ParsedFeature>) {
match kml {
Kml::KmlDocument(doc) => {
for el in &doc.elements {
walk(el, parent_name, out);
}
}
Kml::Document { elements, .. } => {
for el in elements {
walk(el, parent_name, out);
}
}
Kml::Folder(folder) => {
for el in &folder.elements {
walk(el, folder.name.as_deref(), out);
}
}
Kml::Placemark(p) => {
let name = p.name.clone();
if let Some(g) = &p.geometry {
extract(g, name.as_deref(), p, out);
}
}
_ => {}
}
}
fn extract(g: &KmlGeom, name: Option<&str>, _p: &Placemark, out: &mut Vec<ParsedFeature>) {
match g {
KmlGeom::Polygon(poly) => {
let outer: Vec<Coord<f64>> = poly
.outer
.coords
.iter()
.map(|c| Coord { x: c.x, y: c.y })
.collect();
if outer.len() < 4 {
return;
}
let inners: Vec<LineString<f64>> = poly
.inner
.iter()
.map(|ring| {
LineString(
ring.coords
.iter()
.map(|c| Coord { x: c.x, y: c.y })
.collect(),
)
})
.collect();
let geo_poly = GeoPolygon::new(LineString(outer), inners);
let geojson = polygon_to_geojson(&geo_poly);
out.push(ParsedFeature {
name: name.map(|s| s.to_string()),
geom: ScopeGeom::Polygon(geo_poly),
geojson,
geom_type: "polygon",
});
}
KmlGeom::LineString(ls) => {
let pts: Vec<Coord<f64>> = ls
.coords
.iter()
.map(|c| Coord { x: c.x, y: c.y })
.collect();
if pts.len() < 2 {
return;
}
let line = LineString(pts);
let geojson = linestring_to_geojson(&line);
out.push(ParsedFeature {
name: name.map(|s| s.to_string()),
geom: ScopeGeom::Line(line),
geojson,
geom_type: "linestring",
});
}
KmlGeom::MultiGeometry(mg) => {
for sub in &mg.geometries {
extract(sub, name, _p, out);
}
}
_ => {}
}
}
fn polygon_to_geojson(p: &GeoPolygon<f64>) -> String {
let mut rings: Vec<Vec<[f64; 2]>> = vec![];
rings.push(p.exterior().0.iter().map(|c| [c.x, c.y]).collect());
for inner in p.interiors() {
rings.push(inner.0.iter().map(|c| [c.x, c.y]).collect());
}
serde_json::to_string(&serde_json::json!({
"type": "Polygon",
"coordinates": rings
}))
.unwrap_or_else(|_| "{}".to_string())
}
fn linestring_to_geojson(l: &LineString<f64>) -> String {
let coords: Vec<[f64; 2]> = l.0.iter().map(|c| [c.x, c.y]).collect();
serde_json::to_string(&serde_json::json!({
"type": "LineString",
"coordinates": coords
}))
.unwrap_or_else(|_| "{}".to_string())
}
pub async fn replace_scope(pool: &SqlitePool, features: &[ParsedFeature]) -> Result<()> {
let mut tx = pool.begin().await?;
sqlx::query("DELETE FROM scope_polygons")
.execute(&mut *tx)
.await?;
for f in features {
sqlx::query(
"INSERT INTO scope_polygons(name, geojson, geom_type) VALUES (?, ?, ?)",
)
.bind(&f.name)
.bind(&f.geojson)
.bind(f.geom_type)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
/// in_scope = inside any polygon OR within LINE_PROXIMITY_M of any LineString.
/// Range assets are tagged by midpoint only (v1 limitation).
pub async fn apply_scope_to_assets(
pool: &SqlitePool,
geoms: &[ScopeGeom],
) -> Result<usize> {
if geoms.is_empty() {
// Reset only assets that don't have a manual override.
sqlx::query("UPDATE assets SET in_scope = NULL WHERE manual_scope IS NULL")
.execute(pool)
.await?;
return Ok(0);
}
let rows: Vec<(i64, Option<f64>, Option<f64>)> = sqlx::query_as(
"SELECT id, lat, lng FROM assets WHERE manual_scope IS NULL",
)
.fetch_all(pool)
.await?;
let mut tx = pool.begin().await?;
let mut tagged = 0usize;
for (id, lat, lng) in rows {
let in_scope = match (lat, lng) {
(Some(la), Some(lo)) => {
let p = Point::new(lo, la);
geoms.iter().any(|g| match g {
ScopeGeom::Polygon(poly) => poly.contains(&p),
ScopeGeom::Line(line) => match line.closest_point(&p) {
Closest::SinglePoint(cp) | Closest::Intersection(cp) => {
Geodesic.distance(p, cp) <= LINE_PROXIMITY_M
}
Closest::Indeterminate => false,
},
})
}
_ => false,
};
sqlx::query("UPDATE assets SET in_scope = ? WHERE id = ?")
.bind(in_scope as i64)
.bind(id)
.execute(&mut *tx)
.await?;
tagged += 1;
}
tx.commit().await?;
Ok(tagged)
}
pub async fn load_geoms(pool: &SqlitePool) -> Result<Vec<ScopeGeom>> {
let rows: Vec<(String, String)> =
sqlx::query_as("SELECT geojson, geom_type FROM scope_polygons")
.fetch_all(pool)
.await?;
let mut out = vec![];
for (gj, gt) in rows {
let value = match serde_json::from_str::<serde_json::Value>(&gj) {
Ok(v) => v,
Err(_) => continue,
};
let coords = match value.get("coordinates") {
Some(c) => c,
None => continue,
};
if gt == "polygon" {
let arr = match coords.as_array() {
Some(a) => a,
None => continue,
};
let mut rings: Vec<LineString<f64>> = vec![];
for ring in arr {
if let Some(ring_arr) = ring.as_array() {
let pts: Vec<Coord<f64>> = ring_arr
.iter()
.filter_map(|p| p.as_array())
.filter_map(|p| {
Some(Coord {
x: p.first()?.as_f64()?,
y: p.get(1)?.as_f64()?,
})
})
.collect();
if pts.len() >= 4 {
rings.push(LineString(pts));
}
}
}
if let Some(outer) = rings.first().cloned() {
let inners: Vec<LineString<f64>> = rings.into_iter().skip(1).collect();
out.push(ScopeGeom::Polygon(GeoPolygon::new(outer, inners)));
}
} else if gt == "linestring" {
let arr = match coords.as_array() {
Some(a) => a,
None => continue,
};
let pts: Vec<Coord<f64>> = arr
.iter()
.filter_map(|p| p.as_array())
.filter_map(|p| {
Some(Coord {
x: p.first()?.as_f64()?,
y: p.get(1)?.as_f64()?,
})
})
.collect();
if pts.len() >= 2 {
out.push(ScopeGeom::Line(LineString(pts)));
}
}
}
Ok(out)
}

39
src-tauri/tauri.conf.json Normal file
View File

@@ -0,0 +1,39 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "kml_map_tool",
"version": "0.1.0",
"identifier": "com.seekright.kmlmap",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "kml_map_tool",
"width": 800,
"height": 600
}
],
"security": {
"csp": null,
"assetProtocol": {
"enable": true,
"scope": ["**"]
}
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}