lasso bugs

This commit is contained in:
2026-06-10 19:54:10 +05:30
parent ea7730bef9
commit 49a4b51d75
3 changed files with 904 additions and 12 deletions

View File

@@ -7232,3 +7232,358 @@ pub async fn export_bboxes_to_json(
tokio::fs::write(&path, body).await.map_err(|e| e.to_string())?;
Ok(n)
}
// ---------- equalize_along_road -------------------------------------------
//
// Redistribute a set of "uniformly-spaced" assets (e.g. streetlights) so the
// arc-length spacing along the nearest OSM road is constant between two
// anchors. Returns a proposal (no DB writes) so the frontend can show the
// gap-anomaly report and let the user confirm before applying.
//
// Why arc-length not Euclidean: on curves, equal Euclidean spacing pushes
// markers off the road. Arc-length spacing keeps them on the curve.
// Lateral offset uses the avg perpendicular distance of the two anchors from
// the road centerline, so left-side stays left and right-side stays right
// even through bends.
#[derive(Debug, Serialize)]
pub struct EqualizationProposal {
pub asset_id: i64,
pub orig_lat: f64,
pub orig_lng: f64,
pub new_lat: f64,
pub new_lng: f64,
pub displacement_m: f64,
}
#[derive(Debug, Serialize)]
pub struct GapAnomaly {
pub before_id: i64,
pub after_id: i64,
pub gap_m: f64,
pub expected_missing: i64,
}
#[derive(Debug, Serialize)]
pub struct EqualizationResult {
pub proposals: Vec<EqualizationProposal>,
pub anomalies: Vec<GapAnomaly>,
pub median_gap_m: f64,
pub arc_length_m: f64,
pub road_id: i64,
pub lateral_offset_m: f64,
}
/// Project (lng, lat) onto every segment of `line`, return the closest
/// (arc_position_m, snap_lng, snap_lat, signed_perpendicular_m). Sign of
/// perpendicular: cross product (segment × asset-from-snap) — positive ⇒
/// asset is to the left of the segment heading.
fn project_onto_polyline(
line: &LineString<f64>,
lng: f64,
lat: f64,
) -> (f64, f64, f64, f64) {
let pt = Point::new(lng, lat);
let mut best_dist = f64::INFINITY;
let mut best_arc = 0.0;
let mut best_x = lng;
let mut best_y = lat;
let mut best_dx = 0.0_f64;
let mut best_dy = 0.0_f64;
let mut cumulative = 0.0;
for seg in line.lines() {
let snapped = match LineString(vec![seg.start, seg.end]).closest_point(&pt) {
Closest::SinglePoint(p) | Closest::Intersection(p) => p,
Closest::Indeterminate => continue,
};
let d = Geodesic.distance(pt, snapped);
let seg_len = Geodesic.distance(
Point::new(seg.start.x, seg.start.y),
Point::new(seg.end.x, seg.end.y),
);
if d < best_dist {
best_dist = d;
best_x = snapped.x();
best_y = snapped.y();
best_dx = seg.end.x - seg.start.x;
best_dy = seg.end.y - seg.start.y;
// Arc from seg start to snapped point. Use straight-line ratio in
// lat/lng space — accurate for the short segments OSM produces.
let t_dx = snapped.x() - seg.start.x;
let t_dy = snapped.y() - seg.start.y;
let raw_dx = seg.end.x - seg.start.x;
let raw_dy = seg.end.y - seg.start.y;
let raw_len_sq = raw_dx * raw_dx + raw_dy * raw_dy;
let t = if raw_len_sq > 1e-18 {
((t_dx * raw_dx + t_dy * raw_dy) / raw_len_sq).clamp(0.0, 1.0)
} else {
0.0
};
best_arc = cumulative + t * seg_len;
}
cumulative += seg_len;
}
// Signed perpendicular distance in metres.
let m_per_lat = 111_320.0_f64;
let m_per_lng = 111_320.0_f64
* (best_y * std::f64::consts::PI / 180.0).cos().abs().max(1e-6);
let dx_m = best_dx * m_per_lng;
let dy_m = best_dy * m_per_lat;
let ax_m = (lng - best_x) * m_per_lng;
let ay_m = (lat - best_y) * m_per_lat;
let len = (dx_m * dx_m + dy_m * dy_m).sqrt().max(1e-9);
let cross = dx_m * ay_m - dy_m * ax_m;
let perp_signed = cross / len;
(best_arc, best_x, best_y, perp_signed)
}
/// Return (lng, lat, tangent_dx_lng, tangent_dy_lat) of the point at
/// `target_arc` metres along `line`. None if `target_arc` is past the end.
fn point_at_arc(
line: &LineString<f64>,
target_arc: f64,
) -> Option<(f64, f64, f64, f64)> {
let mut cumulative = 0.0;
for seg in line.lines() {
let seg_len = Geodesic.distance(
Point::new(seg.start.x, seg.start.y),
Point::new(seg.end.x, seg.end.y),
);
if cumulative + seg_len + 1e-6 >= target_arc {
let t = if seg_len > 1e-6 {
(target_arc - cumulative) / seg_len
} else {
0.0
};
let lng = seg.start.x + t * (seg.end.x - seg.start.x);
let lat = seg.start.y + t * (seg.end.y - seg.start.y);
return Some((lng, lat, seg.end.x - seg.start.x, seg.end.y - seg.start.y));
}
cumulative += seg_len;
}
None
}
async fn find_nearest_road_id(
pool: &sqlx::SqlitePool,
lat: f64,
lng: f64,
max_d: f64,
) -> Result<(i64, LineString<f64>), String> {
let pad_lat = max_d / 111_320.0;
let pad_lng = max_d
/ (111_320.0
* (lat * std::f64::consts::PI / 180.0).cos().abs().max(1e-6));
let rows: Vec<(i64, String)> = sqlx::query_as(
"SELECT r.id, r.geojson
FROM roads r JOIN roads_rtree x ON x.id = r.id
WHERE x.max_lat >= ? AND x.min_lat <= ?
AND x.max_lng >= ? AND x.min_lng <= ?
AND COALESCE(r.snap_ignored, 0) = 0",
)
.bind(lat - pad_lat)
.bind(lat + pad_lat)
.bind(lng - pad_lng)
.bind(lng + pad_lng)
.fetch_all(pool)
.await
.map_err(|e| e.to_string())?;
let pt = Point::new(lng, lat);
let mut best: Option<(f64, i64, LineString<f64>)> = None;
for (id, gj) in rows {
let Some(line) = parse_linestring(&gj) else { continue };
let mut min_d = f64::INFINITY;
for seg in line.lines() {
let snapped = match LineString(vec![seg.start, seg.end]).closest_point(&pt) {
Closest::SinglePoint(p) | Closest::Intersection(p) => p,
Closest::Indeterminate => continue,
};
let d = Geodesic.distance(pt, snapped);
if d < min_d {
min_d = d;
}
}
if min_d.is_finite()
&& min_d <= max_d
&& best.as_ref().map(|b| min_d < b.0).unwrap_or(true)
{
best = Some((min_d, id, line));
}
}
let (_, id, line) =
best.ok_or_else(|| format!("no road within {:.0} m of anchor", max_d))?;
Ok((id, line))
}
#[tauri::command]
pub async fn equalize_along_road(
anchor_a_id: i64,
anchor_b_id: i64,
asset_ids: Vec<i64>,
max_road_distance_m: Option<f64>,
state: State<'_, AppState>,
) -> Result<EqualizationResult, String> {
let max_d = max_road_distance_m.unwrap_or(50.0);
if anchor_a_id == anchor_b_id {
return Err("two distinct anchors required".into());
}
if asset_ids.is_empty() {
return Err("no assets selected to redistribute".into());
}
// Fetch positions.
let mut all_ids: Vec<i64> = Vec::with_capacity(2 + asset_ids.len());
all_ids.push(anchor_a_id);
all_ids.push(anchor_b_id);
all_ids.extend(&asset_ids);
let placeholders = vec!["?"; all_ids.len()].join(",");
let sql = format!(
"SELECT id, lat, lng FROM assets WHERE id IN ({}) AND deleted = 0",
placeholders
);
let mut q = sqlx::query_as::<_, (i64, Option<f64>, Option<f64>)>(&sql);
for id in &all_ids {
q = q.bind(id);
}
let rows = q.fetch_all(&state.pool).await.map_err(|e| e.to_string())?;
let mut pos: std::collections::HashMap<i64, (f64, f64)> =
std::collections::HashMap::new();
for (id, lat, lng) in rows {
if let (Some(la), Some(lo)) = (lat, lng) {
pos.insert(id, (la, lo));
}
}
let (a_lat, a_lng) = *pos
.get(&anchor_a_id)
.ok_or("anchor A has no position")?;
let (b_lat, b_lng) = *pos
.get(&anchor_b_id)
.ok_or("anchor B has no position")?;
// Locate the road for each anchor; require both on the same OSM way.
let (road_a_id, line_a) = find_nearest_road_id(&state.pool, a_lat, a_lng, max_d).await?;
let (road_b_id, _) = find_nearest_road_id(&state.pool, b_lat, b_lng, max_d).await?;
if road_a_id != road_b_id {
return Err(format!(
"anchors are on different OSM roads ({} vs {}). Multi-way stitching isn't supported yet — drop a road that covers both anchors.",
road_a_id, road_b_id
));
}
let line = line_a;
// Project anchors + assets.
let proj_a = project_onto_polyline(&line, a_lng, a_lat);
let proj_b = project_onto_polyline(&line, b_lng, b_lat);
let (mut s_lo, mut s_hi) = (proj_a.0, proj_b.0);
let lo_id = if s_lo <= s_hi {
anchor_a_id
} else {
std::mem::swap(&mut s_lo, &mut s_hi);
anchor_b_id
};
let hi_id = if lo_id == anchor_a_id { anchor_b_id } else { anchor_a_id };
let arc_length = s_hi - s_lo;
if arc_length < 1.0 {
return Err("anchors are within 1 m on the road — nothing to redistribute".into());
}
let mut asset_projs: Vec<(i64, f64, f64, f64)> = Vec::new();
for id in &asset_ids {
if *id == anchor_a_id || *id == anchor_b_id {
continue;
}
let Some((la, lo)) = pos.get(id).copied() else { continue };
let p = project_onto_polyline(&line, lo, la);
// Allow a 1 m slop so an asset sitting exactly on an anchor counts.
if p.0 >= s_lo - 1.0 && p.0 <= s_hi + 1.0 {
asset_projs.push((*id, p.0, la, lo));
}
}
asset_projs.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
if asset_projs.is_empty() {
return Err(
"none of the selected assets project onto the road segment between the two anchors"
.into(),
);
}
// Build the full ordered sequence with anchors at both ends for gap stats.
let mut sequence: Vec<(i64, f64)> = Vec::with_capacity(asset_projs.len() + 2);
sequence.push((lo_id, s_lo));
for ap in &asset_projs {
sequence.push((ap.0, ap.1));
}
sequence.push((hi_id, s_hi));
let mut gaps: Vec<f64> = Vec::with_capacity(sequence.len() - 1);
for i in 0..sequence.len() - 1 {
gaps.push((sequence[i + 1].1 - sequence[i].1).max(0.0));
}
let mut sorted_gaps = gaps.clone();
sorted_gaps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let median_gap = sorted_gaps[sorted_gaps.len() / 2];
// Anomaly detection: a gap is flagged when it's much larger than the
// median AND larger than a minimum absolute floor (so a 0.5 m → 1 m gap
// doesn't spam warnings).
const ANOMALY_RATIO: f64 = 1.8;
const MIN_GAP_FOR_ANOMALY_M: f64 = 5.0;
let mut anomalies: Vec<GapAnomaly> = Vec::new();
for (i, &g) in gaps.iter().enumerate() {
if g > median_gap * ANOMALY_RATIO && g > MIN_GAP_FOR_ANOMALY_M && median_gap > 1e-3 {
let expected_missing = ((g / median_gap).round() as i64 - 1).max(1);
anomalies.push(GapAnomaly {
before_id: sequence[i].0,
after_id: sequence[i + 1].0,
gap_m: g,
expected_missing,
});
}
}
// Average signed perpendicular offset of the anchors — used as the
// lateral offset for every redistributed point. Curvature is handled by
// computing the perpendicular direction PER point at its new tangent.
let lateral_offset = (proj_a.3 + proj_b.3) / 2.0;
// Equalize: N intermediate points, equally spaced between s_lo and s_hi.
let n = asset_projs.len();
let step = arc_length / (n as f64 + 1.0);
let mut proposals: Vec<EqualizationProposal> = Vec::with_capacity(n);
for (i, ap) in asset_projs.iter().enumerate() {
let new_arc = s_lo + step * (i as f64 + 1.0);
let Some((cx, cy, dx_lng, dy_lat)) = point_at_arc(&line, new_arc) else {
continue;
};
let m_per_lat = 111_320.0_f64;
let m_per_lng = 111_320.0_f64
* (cy * std::f64::consts::PI / 180.0).cos().abs().max(1e-6);
let dx_m = dx_lng * m_per_lng;
let dy_m = dy_lat * m_per_lat;
let len = (dx_m * dx_m + dy_m * dy_m).sqrt().max(1e-9);
// Left-normal of the tangent (matches the sign convention of
// project_onto_polyline so positive perp keeps the same side).
let nx = -dy_m / len;
let ny = dx_m / len;
let new_lng = cx + nx * lateral_offset / m_per_lng;
let new_lat = cy + ny * lateral_offset / m_per_lat;
let displacement_m = haversine_m((ap.2, ap.3), (new_lat, new_lng));
proposals.push(EqualizationProposal {
asset_id: ap.0,
orig_lat: ap.2,
orig_lng: ap.3,
new_lat,
new_lng,
displacement_m,
});
}
Ok(EqualizationResult {
proposals,
anomalies,
median_gap_m: median_gap,
arc_length_m: arc_length,
road_id: road_a_id,
lateral_offset_m: lateral_offset,
})
}

View File

@@ -8,7 +8,7 @@ use commands::{
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,
add_class, auto_link_in_polygon, bulk_translate, clear_assets_by_type,
add_class, auto_link_in_polygon, bulk_translate, clear_assets_by_type, equalize_along_road,
clear_osm_roads, clear_scope_polygons, data_source_counts, change_assets_side, clear_link, clear_manual_scope,
create_osm_road, create_user, delete_road_section, delete_road_vertices,
delete_roads_in_lasso, set_link, simplify_road,
@@ -124,7 +124,8 @@ pub fn run() {
clear_asset_bbox,
export_bboxes_to_json,
set_link,
clear_link
clear_link,
equalize_along_road
])
.run(tauri::generate_context!())
.expect("error while running tauri application");