osm generate code fix
This commit is contained in:
@@ -3711,57 +3711,98 @@ pub struct BboxOut {
|
||||
const OSM_HIGHWAY_CLASSES: &str =
|
||||
"motorway|trunk|primary|secondary|tertiary|motorway_link|trunk_link|primary_link|secondary_link|tertiary_link|residential|unclassified|service";
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GenerateOsmResult {
|
||||
pub ways_kept: usize,
|
||||
pub source: String,
|
||||
pub points_used: usize,
|
||||
pub videos_used: usize,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn generate_osm_near_assets(
|
||||
radius_m: Option<f64>,
|
||||
max_samples: Option<usize>,
|
||||
min_lat: f64,
|
||||
min_lng: f64,
|
||||
max_lat: f64,
|
||||
max_lng: f64,
|
||||
keep_threshold_m: Option<f64>,
|
||||
path: String,
|
||||
overpass_url: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<usize, String> {
|
||||
// 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
|
||||
) -> Result<GenerateOsmResult, String> {
|
||||
// New flow: Overpass query is the visible bbox (gives every way in view,
|
||||
// including highways that earlier around: + min_votes=2 dropped). The
|
||||
// returned ways are then filtered against a unified reference set built
|
||||
// from per-video metadata polyline points + live asset coords. A way is
|
||||
// kept if any of its segments comes within `keep_threshold_m` of any
|
||||
// reference point. This covers highways with sparse assets via the
|
||||
// metadata polyline (the GPS track always touches the highway it drove)
|
||||
// and urban side streets via the dense asset cloud.
|
||||
let threshold_m = keep_threshold_m.unwrap_or(50.0).max(1.0);
|
||||
if !(min_lat < max_lat) || !(min_lng < max_lng) {
|
||||
return Err("invalid bbox: min must be < max for both lat and lng".into());
|
||||
}
|
||||
|
||||
// --- Build the reference-point set (lng, lat). ---
|
||||
// Pull asset centres for the post-filter votes.
|
||||
let asset_coords: Vec<(f64, f64)> = sqlx::query_as::<_, (f64, f64)>(
|
||||
"SELECT lng, lat FROM assets
|
||||
WHERE deleted = 0 AND lat IS NOT NULL AND lng IS NOT NULL",
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
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(',');
|
||||
// Pull every metadata polyline point (DB-persisted, stored as
|
||||
// [[lng, lat], ...] to match deck.gl convention).
|
||||
let metadata_rows: Vec<(String,)> = sqlx::query_as(
|
||||
"SELECT track_json FROM video_metadata",
|
||||
)
|
||||
.fetch_all(&state.pool)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut ref_points: Vec<(f64, f64)> = Vec::new();
|
||||
let mut videos_used = 0usize;
|
||||
for (json,) in &metadata_rows {
|
||||
let track: Vec<[f64; 2]> = match serde_json::from_str(json) {
|
||||
Ok(t) => t,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if track.is_empty() {
|
||||
continue;
|
||||
}
|
||||
videos_used += 1;
|
||||
for p in track {
|
||||
ref_points.push((p[0], p[1]));
|
||||
}
|
||||
latlng.push_str(&format!("{},{}", lat, lng));
|
||||
}
|
||||
for c in &asset_coords {
|
||||
ref_points.push(*c);
|
||||
}
|
||||
if ref_points.is_empty() {
|
||||
return Err(
|
||||
"nothing to filter against. Import per-video metadata or assets first \
|
||||
so the generator knows which Overpass-returned roads to keep."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let source = if videos_used > 0 && !asset_coords.is_empty() {
|
||||
"metadata+assets"
|
||||
} else if videos_used > 0 {
|
||||
"metadata"
|
||||
} else {
|
||||
"assets"
|
||||
};
|
||||
|
||||
// --- Overpass: bbox query, no around: list. ---
|
||||
let query = format!(
|
||||
"[out:json][timeout:180];\
|
||||
(way[\"highway\"~\"{classes}\"](around:{r},{pts}););\
|
||||
(way[\"highway\"~\"{classes}\"]({mn_lat},{mn_lng},{mx_lat},{mx_lng}););\
|
||||
out body geom;",
|
||||
classes = OSM_HIGHWAY_CLASSES,
|
||||
r = radius as i64,
|
||||
pts = latlng,
|
||||
mn_lat = min_lat,
|
||||
mn_lng = min_lng,
|
||||
mx_lat = max_lat,
|
||||
mx_lng = max_lng,
|
||||
);
|
||||
let url = overpass_url
|
||||
.unwrap_or_else(|| "https://overpass-api.de/api/interpreter".to_string());
|
||||
@@ -3843,16 +3884,14 @@ pub async fn generate_osm_near_assets(
|
||||
}));
|
||||
}
|
||||
|
||||
// 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(
|
||||
// Post-filter: keep a way if any of its segments comes within
|
||||
// `threshold_m` of any reference point (metadata polyline points or
|
||||
// asset coords). Permissive — needs only ONE hit, vs the old
|
||||
// min_votes=2 which dropped sparse-asset highways.
|
||||
let kept = filter_features_by_reference_points(
|
||||
features,
|
||||
&coords,
|
||||
keep_threshold_m,
|
||||
min_votes,
|
||||
&ref_points,
|
||||
threshold_m,
|
||||
);
|
||||
|
||||
let fc = serde_json::json!({
|
||||
@@ -3863,7 +3902,14 @@ pub async fn generate_osm_near_assets(
|
||||
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)
|
||||
Ok(GenerateOsmResult {
|
||||
ways_kept: count,
|
||||
source: source.to_string(),
|
||||
// Reuse points_used to surface the size of the reference set the
|
||||
// post-filter checked against (asset coords + every metadata vertex).
|
||||
points_used: ref_points.len(),
|
||||
videos_used,
|
||||
})
|
||||
}
|
||||
|
||||
fn kept_count(fc: &serde_json::Value) -> usize {
|
||||
@@ -3905,12 +3951,28 @@ fn point_segment_distance_m(
|
||||
(ex * ex + ey * ey).sqrt()
|
||||
}
|
||||
|
||||
fn filter_features_by_asset_votes(
|
||||
/// Permissive filter: keep a way if any of its segments comes within
|
||||
/// `threshold_m` of any reference point. Reference points are passed as
|
||||
/// `(lng, lat)`. Single hit is enough — designed for the bbox+filter flow
|
||||
/// where we want every road touched by either metadata polylines or assets,
|
||||
/// including highways with sparse asset coverage.
|
||||
///
|
||||
/// Builds a spatial-hash grid keyed by `cell_deg = threshold_m / 111320`,
|
||||
/// then for each way walks its bbox cell + a 1-ring neighbourhood; at most
|
||||
/// one exact distance check per nearby reference point. ~O(W × P_local).
|
||||
fn filter_features_by_reference_points(
|
||||
features: Vec<serde_json::Value>,
|
||||
asset_coords: &[(f64, f64)],
|
||||
ref_points: &[(f64, f64)],
|
||||
threshold_m: f64,
|
||||
min_votes: usize,
|
||||
) -> Vec<serde_json::Value> {
|
||||
use std::collections::HashMap;
|
||||
let cell_deg = (threshold_m / 111_320.0).max(1e-7);
|
||||
let mut grid: HashMap<(i64, i64), Vec<usize>> = HashMap::new();
|
||||
for (i, (lng, lat)) in ref_points.iter().enumerate() {
|
||||
let gx = (lng / cell_deg).floor() as i64;
|
||||
let gy = (lat / cell_deg).floor() as i64;
|
||||
grid.entry((gx, gy)).or_default().push(i);
|
||||
}
|
||||
let mut out: Vec<serde_json::Value> = Vec::new();
|
||||
for feat in features {
|
||||
let pts: Vec<[f64; 2]> = feat
|
||||
@@ -3931,53 +3993,46 @@ fn filter_features_by_asset_votes(
|
||||
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;
|
||||
let mut keep = false;
|
||||
// Walk segments at sub-cell intervals so a long segment crossing a
|
||||
// populated cell can't slip through the grid.
|
||||
let step_deg = cell_deg * 0.5;
|
||||
'segs: for w in pts.windows(2) {
|
||||
let a = w[0];
|
||||
let b = w[1];
|
||||
let dx = b[0] - a[0];
|
||||
let dy = b[1] - a[1];
|
||||
let len = (dx * dx + dy * dy).sqrt();
|
||||
let steps = ((len / step_deg).ceil() as usize).max(1);
|
||||
for s in 0..=steps {
|
||||
let t = s as f64 / steps as f64;
|
||||
let lng = a[0] + dx * t;
|
||||
let lat = a[1] + dy * t;
|
||||
let gx = (lng / cell_deg).floor() as i64;
|
||||
let gy = (lat / cell_deg).floor() as i64;
|
||||
for dy_c in -1..=1 {
|
||||
for dx_c in -1..=1 {
|
||||
if let Some(idxs) = grid.get(&(gx + dx_c, gy + dy_c)) {
|
||||
for &idx in idxs {
|
||||
let p = ref_points[idx];
|
||||
// Cheap equirectangular distance — accurate
|
||||
// enough for 50 m thresholding.
|
||||
let lat0 = ((lat + p.1) * 0.5).to_radians();
|
||||
let m_per_lng = (111_320.0_f64 * lat0.cos()).max(1.0);
|
||||
let mx = (p.0 - lng) * m_per_lng;
|
||||
let my = (p.1 - lat) * 111_320.0;
|
||||
let d = (mx * mx + my * my).sqrt();
|
||||
if d <= threshold_m {
|
||||
keep = true;
|
||||
break 'segs;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if votes >= min_votes {
|
||||
if keep {
|
||||
out.push(feat);
|
||||
}
|
||||
}
|
||||
|
||||
116
src/App.tsx
116
src/App.tsx
@@ -207,7 +207,14 @@ function AppShell({
|
||||
range_assets: number;
|
||||
osm_roads: number;
|
||||
scope_features: number;
|
||||
}>({ fixed_assets: 0, range_assets: 0, osm_roads: 0, scope_features: 0 });
|
||||
video_metadata: number;
|
||||
}>({
|
||||
fixed_assets: 0,
|
||||
range_assets: 0,
|
||||
osm_roads: 0,
|
||||
scope_features: 0,
|
||||
video_metadata: 0,
|
||||
});
|
||||
const [recentActions, setRecentActions] = useState<ActionRow[]>([]);
|
||||
const [status, setStatus] = useState<string>("");
|
||||
const [busy, setBusy] = useState<boolean>(false);
|
||||
@@ -1580,6 +1587,7 @@ function AppShell({
|
||||
range_assets: number;
|
||||
osm_roads: number;
|
||||
scope_features: number;
|
||||
video_metadata: number;
|
||||
}>("data_source_counts");
|
||||
setDataCounts(c);
|
||||
} catch {
|
||||
@@ -1830,46 +1838,82 @@ function AppShell({
|
||||
folder.endsWith("/") || folder.endsWith("\\") ? "" : "/";
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
const hasAssets = dataCounts.fixed_assets + dataCounts.range_assets > 0;
|
||||
const hasMetadata = (dataCounts.video_metadata ?? 0) > 0
|
||||
|| Object.keys(metadataByVideo).length > 0;
|
||||
if (!hasAssets && !hasMetadata) {
|
||||
setStatus(
|
||||
"Import per-video metadata or assets first — the generator filters Overpass results against them.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// We need a bbox to query Overpass against. Prefer the current visible
|
||||
// viewport (gives the user direct control over scope); fall back to the
|
||||
// bbox of all loaded assets if the viewport isn't valid yet (e.g. user
|
||||
// never panned the map). Either way the bbox is what Overpass filters
|
||||
// on, and the post-filter then narrows by metadata + asset proximity.
|
||||
let b = boundsRef.current;
|
||||
if (!b) {
|
||||
try {
|
||||
const ab = await invoke<{
|
||||
min_lat: number;
|
||||
min_lng: number;
|
||||
max_lat: number;
|
||||
max_lng: number;
|
||||
} | null>("assets_bbox");
|
||||
if (ab) {
|
||||
b = {
|
||||
south: ab.min_lat,
|
||||
north: ab.max_lat,
|
||||
west: ab.min_lng,
|
||||
east: ab.max_lng,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* fall through to error below */
|
||||
}
|
||||
}
|
||||
if (!b) {
|
||||
setStatus(
|
||||
"No visible map bbox and no asset bbox available — pan/zoom the map or import assets first.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setOsmFetchActive(true);
|
||||
try {
|
||||
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<number>("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<number>("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.",
|
||||
);
|
||||
}
|
||||
const sourceHint = hasMetadata && hasAssets
|
||||
? "metadata + assets"
|
||||
: hasMetadata
|
||||
? "metadata"
|
||||
: "assets";
|
||||
setStatus(
|
||||
`Fetching OSM for visible bbox from Overpass, then filtering by ${sourceHint}…`,
|
||||
);
|
||||
const path = `${folder}${sep}osm_on_asset_roads_${ts}.geojson`;
|
||||
const r = await invoke<{
|
||||
ways_kept: number;
|
||||
source: string;
|
||||
points_used: number;
|
||||
videos_used: number;
|
||||
}>("generate_osm_near_assets", {
|
||||
minLat: b.south,
|
||||
minLng: b.west,
|
||||
maxLat: b.north,
|
||||
maxLng: b.east,
|
||||
keepThresholdM: 50,
|
||||
path,
|
||||
});
|
||||
const refDesc =
|
||||
r.videos_used > 0
|
||||
? `${r.points_used} ref points (${r.videos_used} video track(s) + assets)`
|
||||
: `${r.points_used} asset ref points`;
|
||||
setStatus(
|
||||
`Generated ${r.ways_kept} road(s) [bbox + ${r.source}, ${refDesc}] → ${path}. Use Import → OSM roads to load.`,
|
||||
);
|
||||
return;
|
||||
} catch (e) {
|
||||
setStatus(`Generate OSM failed: ${e}`);
|
||||
return;
|
||||
} finally {
|
||||
setOsmFetchActive(false);
|
||||
setBusy(false);
|
||||
|
||||
Reference in New Issue
Block a user