lasso bugs
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user