Repository: a-b-street/ltn
Pull request #293: reclassify many
Author: login: michaelkirk
Created: 2025-04-02T02:52:42Z | Merged: 2025-04-02T22:17:15Z

Fixes #278

I went with the "drawing" approach. The line matching logic ended up being pretty complicated, which I don't like. I do like that this approach is incremental, rather than "one shot" UI, which lets the user interactively get closer to where they want to be. I think it works well enough that it's helpful, so I'm in favor of merging it, but let me know what you think!

https://github.com/user-attachments/assets/712e5478-4913-4068-a321-ca4df1f25f57



Linked issue #278: Bulk edit main road classification
Apply one-way and main road changes to one road segment at a time can be painful. Some ideas for bulk selecting:

1) Group by contiguous road names. Maybe we produce a different GJ linestring dataset with these merged cases, and have the regular hover behavior, or maybe we keep the current per-road GJ data and just have a maplibre filter to show all matching road names

2) Route snapping tool, like the one used in route mode

3) A freehand paint tool, like "draw filters along line"

Diff:
diff --git a/backend/src/geo_helpers/mod.rs b/backend/src/geo_helpers/mod.rs
index fc9b882f..eb0707f8 100644
--- a/backend/src/geo_helpers/mod.rs
+++ b/backend/src/geo_helpers/mod.rs
@@ -1,6 +1,9 @@
 mod slice_nearest_boundary;
 pub use slice_nearest_boundary::SliceNearestFrechetBoundary;
 
+mod roads_along_line;
+pub use roads_along_line::roads_along_line;
+
 use geo::line_measures::InterpolatableLine;
 use geo::{
     BooleanOps, BoundingRect, Buffer, Contains, Coord, Distance, Euclidean, Intersects, Length,
diff --git a/backend/src/geo_helpers/roads_along_line.rs b/backend/src/geo_helpers/roads_along_line.rs
new file mode 100644
index 00000000..ae7038c3
--- /dev/null
+++ b/backend/src/geo_helpers/roads_along_line.rs
@@ -0,0 +1,237 @@
+use crate::geo_helpers::slice_nearest_boundary::SliceNearEndpoints;
+use crate::{Road, RoadID};
+use geo::line_measures::{Densifiable, LengthMeasurable};
+use geo::{Euclidean, HausdorffDistance, LineString};
+use std::collections::BTreeSet;
+
+pub fn roads_along_line<'a>(
+    roads: impl Iterator<Item = &'a Road>,
+    line_string: &LineString,
+) -> Vec<RoadID> {
+    let mut road_likelihoods = vec![];
+
+    for road in roads {
+        // Higher score means higher likelihood that the road lies along the drawn line.
+        let score = {
+            // Hausdorff distance measures a type of linestring similarity:
+            //   1. Get the set of shortest distances between all points on the two linestrings.
+            //   2. The minimum of those shortest distances would be the classic "Distance" calculation between linestrings,
+            //      though it's only computed between nodes.
+            //   3. Whereas the longest of those shortest distances is the "Hausdorff Distance".
+
+            // The line we draw will usually be much longer than the individual road segments we're
+            // matching against. If we were to consider the entire drawn line, most of it would be
+            // very far (in hausdorff terms) from any individual segment.
+            // So we trim the drawn line back near the endpoints of the segment we're considering.
+            //
+            // Well matched roads, lying along the drawn line, will tend to be about as long as
+            // the trimmed drawn line.
+            // Whereas perpendicular roads tend to have a very short trimmed drawn line, and should
+            // be punished.
+            let trimmed_line_string = line_string.slice_near_endpoints(&road.linestring);
+            let trimmed_line_length = trimmed_line_string.length(&Euclidean);
+            let road_length = road.linestring.length(&Euclidean);
+            let trimming_ratio = ((road_length - trimmed_line_length) / trimmed_line_length).abs();
+            let trimming_score = 1.0 / (1.0 + trimming_ratio);
+
+            // Hausdorff distance only computes distance between nodes, so we need to make sure our
+            // road is thoroughly densified.
+            // Densifying the drawn line_string will have minimal impact on Hausdorff distance,
+            // so we skip it.
+            let densified_road = road.linestring.densify(&Euclidean, 1.0);
+
+            let hausdorff_distance = trimmed_line_string.hausdorff_distance(&densified_road);
+
+            // Hausdorff distance is bounded by the length of the road, so taking raw Hausdorff
+            // distance naively could unfairly benefit short segments.
+            //
+            // e.g. Imagine we're tracing along a 100 meter main road.
+            // Our drawn line could reasonably deviate up to 10 meters from the road we're tracing,
+            // giving us a Hausdorff distance of 10 meters.
+            //
+            // Now imagine that main road has a bunch of intersecting perpendicular 10 meter drive ways.
+            // Those driveways all have a Hausdorff distance of 10 meters.
+            //
+            // So: compute the delta of `road_length - hausdorff` to penalize short roads
+            let hausdorff_delta = road_length - hausdorff_distance;
+
+            // By itself, that delta implies, given a long enough road, Hausdorff distance becomes
+            // irrelevant. To account for that we also divide by the road length.
+            let hausdorff_score = hausdorff_delta / road_length;
+
+            hausdorff_score + trimming_score
+        };
+
+        road_likelihoods.push((road, score));
+    }
+
+    // We might want to tweak this if it's too strict/loose
+    // We could also parameterize it, based on zoom. When zoomed in you can be more precise.
+    let threshold = 1.5;
+
+    let mut along = vec![];
+    let mut not_along = vec![];
+
+    let mut along_i = BTreeSet::new();
+    for (road, score) in road_likelihoods {
+        if score >= threshold {
+            along.push((road, score));
+            along_i.insert(road.src_i);
+            along_i.insert(road.dst_i);
+        } else {
+            not_along.push((road, score));
+        }
+    }
+
+    // Try to add in any missed connections
+    //
+    // It's easy to miss short connections between segments if your line
+    // happens to deviate a little when crossing the connection.
+    //
+    // So give a bonus to anything connected to our existing `along` lines.
+    let mut new_connections = vec![];
+    loop {
+        for (idx, (road, mut score)) in not_along.iter().enumerate() {
+            let connected_bonus = threshold * 0.3;
+            if along_i.contains(&road.src_i) {
+                score += connected_bonus;
+            }
+            if along_i.contains(&road.dst_i) {
+                score += connected_bonus;
+            }
+            if score >= threshold {
+                new_connections.push(idx);
+            }
+        }
+
+        // Adding a connection might recursively enable new connections,
+        // so repeat until no new connections are added.
+        if new_connections.is_empty() {
+            break;
+        } else {
+            while let Some(last_idx) = new_connections.pop() {
+                let road_score = not_along.remove(last_idx);
+                along.push(road_score);
+            }
+        }
+    }
+
+    if along.is_empty() {
+        // No good matches, but we know the user was trying to draw *something*,
+        // Make our best guess so they can make progress.
+        // This is especially helpful when doing cleanup to get any final little leftover segments.
+        if let Some(closest) = not_along.iter().max_by(|(_r1, score1), (_r2, score2)| {
+            score1
+                .partial_cmp(score2)
+                .unwrap_or(std::cmp::Ordering::Equal)
+        }) {
+            vec![closest.0.id]
+        } else {
+            vec![]
+        }
+    } else {
+        along.into_iter().map(|(road, _)| road.id).collect()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::geo_helpers::roads_along_line;
+    use crate::{IntersectionID, Road, RoadID};
+    use geo::{wkt, LineString};
+    use osm_reader::WayID;
+    use utils::Tags;
+
+    fn road(id: usize, linestring: LineString) -> Road {
+        // For this test, we don't consider connectedness,
+        // so we give every intersection a unique ID
+        let src_i = IntersectionID(id + 1000);
+        let dst_i = IntersectionID(id + 2000);
+        Road {
+            id: RoadID(id),
+            src_i,
+            dst_i,
+            way: WayID(123),
+            linestring,
+            tags: Tags(Default::default()),
+            speed_mph: 0,
+        }
+    }
+
+    #[test]
+    fn exclude_short_perpendicular_roads() {
+        // Geometries lifted from a Bristol neighbourhood.
+        // Real world geometries lifted from a Bristol neighbourhood.
+        //
+        // If you need to update this test, it'd be reasonable to just grab another set from the
+        // debug console which have the same characteristics, there's nothing particularly sacred about these.
+        let line_string = wkt!(LINESTRING(2714.9251154723443 1405.9543323582727,2710.295719658016 1401.3262811170637,2700.5739885454377 1384.202528156889,2690.852257358002 1360.1368108719553,2687.1487407360883 1346.252795091471,2683.908163672953 1318.4848774612738,2682.519344996617 1266.1890450431092,2681.1305262454234 1251.379703664963,2677.4270096235095 1238.421565400088,2670.019976306795 1224.537882543963,2670.019976306795 1218.5216317629931,2674.649372119153 1206.9519387870262,2674.649372119153 1205.1007903605253,2673.26055336796 1203.7124294657174,2672.3346742504023 1200.9357088517531));
+
+        // EXCLUDE: Cooperage Road - Short stub, perpendicular to line.
+        let road_741 = road(
+            741,
+            wkt!(LINESTRING(2670.734872548158 1258.917339879581,2661.2878993451413 1263.287306533261)),
+        );
+        // EXCLUDE: no name - Short stub, perpendicular to line.
+        let road_3360 = road(
+            3360,
+            wkt!(LINESTRING(2673.2023138249424 1207.600810351793,2658.1134945564545 1210.136058181511)),
+        );
+        // EXCLUDE: no name - Short stub, perpendicular to line, and farther from line
+        let road_3361 = road(
+            3361,
+            wkt!(LINESTRING(2658.1134945564545 1210.136058181511,2648.118971182499 1212.7157840430527)),
+        );
+        // INCLUDE: Netham Road. It's short but somewhat parallel to the road direction.
+        let road_665 = road(
+            665,
+            wkt!(LINESTRING(2673.2023138249424 1207.600810351793,2673.243899913871 1212.0263745454492)),
+        );
+        // INCLUDE: Netham Road. It's medium short but mostly parallel to the road direction.
+        let road_911 = road(
+            911,
+            wkt!(LINESTRING(2676.5292009397203 1252.3234716221045,2680.285810973464 1258.961817912194,2682.3928394794866 1262.6868530993036,2685.075142215785 1269.7699797107846)),
+        );
+        // EXCLUDE: Grindell Road - Short stub, perpendicular to line.
+        let road_2083 = road(
+            2083,
+            wkt!(LINESTRING(2683.2176302433622 1208.4014149293491,2673.243899913871 1212.0263745454492)),
+        );
+        // INCLUDE: Netham Road - long and mostly parallel to the road. Should be included.
+        let road_912 = road(
+            912,
+            wkt!(LINESTRING(2685.075142215785 1269.7699797107846,2685.768243698034 1271.593579026593,2686.0801393650263 1281.1452364180823,2684.964245978623 1297.3241205922984,2682.2126330941205 1316.4385548842208,2681.4710145081012 1321.4756920191976,2682.0740127976524 1327.4802263516601,2692.1586393643365 1363.0848910421403,2697.0311427845145 1380.2867699546082,2702.0907836048996 1392.6294238601815,2707.8573879371565 1400.1684503004171,2723.9304113104217 1411.8550532328468)),
+        );
+
+        let roads = vec![
+            road_741, road_3360, road_3361, road_665, road_911, road_2083, road_912,
+        ];
+
+        let expected = vec![RoadID(665), RoadID(911), RoadID(912)];
+        let actual = roads_along_line(roads.iter(), &line_string);
+        assert_eq!(expected, actual);
+    }
+
+    #[test]
+    fn exclude_long_parallel_roads_that_are_far_away() {
+        // Real world geometries lifted from a Bristol neighbourhood.
+        //
+        // If you need to update this test, it'd be reasonable to just grab another set from the
+        // debug console which have the same characteristics, there's nothing particularly sacred about these.
+        let line_string = wkt!(LINESTRING(2541.7682765144386 1161.2486674255972,2551.3101163852075 1175.1897364336735,2563.053919295652 1189.130843735762,2576.632691413377 1199.0363906121681,2579.9356359975145 1202.3382438848537,2590.9454512451393 1215.9125520617677,2595.7163711549147 1223.6169052903697,2600.120297232296 1228.7531472832736,2608.1941617774264 1232.7887696266173,2608.9281494422153 1241.960650485197,2610.396124815132 1247.0969110451822,2618.8369831434093 1254.80131160641,2625.809866144078 1262.5057238577883));
+
+        let roads = vec![
+            road(
+                743,
+                wkt!(LINESTRING(2631.3805703862904 1275.5854824070113,2599.2067995804837 1289.6627795639567,2589.946963777706 1290.0074843127586,2587.146833789434 1288.0504509006907,2582.911983732924 1285.0926617666776,2521.621019658025 1183.6938681018958,2515.2306239917225 1179.490694068935)),
+            ),
+            road(
+                1124,
+                wkt!(LINESTRING(2631.3805703862904 1275.5854824070113,2612.1262112095264 1240.3811200045193,2606.671502544262 1230.406921307945,2530.8392693718747 1150.8023633684263)),
+            ),
+        ];
+        let expected = vec![RoadID(1124)];
+        let actual = roads_along_line(roads.iter(), &line_string);
+        assert_eq!(expected, actual);
+    }
+}
diff --git a/backend/src/geo_helpers/slice_nearest_boundary.rs b/backend/src/geo_helpers/slice_nearest_boundary.rs
index 70aab637..5cbf495c 100644
--- a/backend/src/geo_helpers/slice_nearest_boundary.rs
+++ b/backend/src/geo_helpers/slice_nearest_boundary.rs
@@ -1,17 +1,26 @@
 use geo::{
-    coord, line_measures::FrechetDistance, Closest, ClosestPoint, Distance, Euclidean,
+    coord, line_measures::FrechetDistance, Closest, ClosestPoint, Coord, Distance, Euclidean,
     HasDimensions, LineString, Point, Polygon,
 };
 use std::cmp::Ordering;
 
-pub trait SliceNearestFrechetBoundary {
-    /// Splits `self.exterior` at the two points nearest to `closest_to.start` and `closest_to.end`
+pub trait SliceNearEndpoints {
+    type SliceType;
+
+    /// returns points on `self` nearest to the beginning and end of `closest_to`, and the segment
+    /// index on `self` where each of those coords lies.
+    ///
+    /// returns (start, end)
+    fn coords_near_endpoints(&self, closest_to: &LineString) -> ((usize, Coord), (usize, Coord));
+
+    /// Splits `self` at the two points nearest to `closest_to.start` and `closest_to.end`
     ///
     /// In the likely event that the closest points on the exterior are not pre-existing vertices,
     /// new coords will be interpolated into the existing segments.
-    fn split_boundary_nearest_endpoints(&self, closest_to: &LineString)
-        -> (LineString, LineString);
+    fn slice_near_endpoints(&self, closest_to: &LineString) -> Self::SliceType;
+}
 
+pub trait SliceNearestFrechetBoundary {
     /// Returns the subset of self.exterior() closest to `closest_to` and its frechet distance.
     ///
     /// i.e. returns one of the LineStrings from `split_boundary_nearest_endpoints`, whichever
@@ -30,8 +39,8 @@ impl SliceNearestFrechetBoundary for Polygon {
         //
         // Of the two parts, the one with the lowest frechet_distance represents the best
         // candidate for its corresponding boundary.
-        let (forwards_half, backwards_half) = self.split_boundary_nearest_endpoints(closest_to);
-        let forwards_frechet = Euclidean.frechet_distance(&forwards_half, &closest_to);
+        let (forwards_half, backwards_half) = self.slice_near_endpoints(closest_to);
+        let forwards_frechet = Euclidean.frechet_distance(&forwards_half, closest_to);
 
         // The second half of the polygon begins where the first half ends, so we
         // need to reverse `closest_to` to get an accurate (minimal) distance measure
@@ -45,63 +54,22 @@ impl SliceNearestFrechetBoundary for Polygon {
             (backwards_half, backwards_frechet)
         }
     }
+}
 
-    fn split_boundary_nearest_endpoints(
-        &self,
-        closest_to: &LineString,
-    ) -> (LineString, LineString) {
-        // Not sure if this will ever be an issue in practice
-        assert!(!closest_to.is_empty(), "we don't yet handle empty input");
-        assert!(
-            !self.exterior().is_empty(),
-            "we don't yet handle empty input"
-        );
+impl SliceNearEndpoints for Polygon {
+    fn coords_near_endpoints(&self, closest_to: &LineString) -> ((usize, Coord), (usize, Coord)) {
+        self.exterior().coords_near_endpoints(closest_to)
+    }
 
+    // A closed ring can be split into two equally plausible (complementing) halves.
+    type SliceType = (LineString, LineString);
+    fn slice_near_endpoints(&self, closest_to: &LineString) -> (LineString, LineString) {
         let exterior = self.exterior();
 
-        let first_coord = *closest_to.0.first().expect("non-empty linestring");
-        let final_coord = *closest_to.0.last().expect("non-empty linestring");
-
-        let mut distance_to_first = f64::MAX;
-        let mut segment_idx_closest_to_first = 0;
-        let mut coord_closest_to_first = coord!(x: 0., y: 0.);
-
-        let mut distance_to_final = f64::MAX;
-        let mut segment_idx_closest_to_final = 0;
-        let mut coord_closest_to_final = coord!(x: 0., y: 0.);
-        for (segment_idx, segment) in exterior.lines().enumerate() {
-            let new_first_distance = Euclidean.distance(&segment, first_coord);
-            if new_first_distance < distance_to_first {
-                distance_to_first = new_first_distance;
-                segment_idx_closest_to_first = segment_idx;
-                coord_closest_to_first = match segment.closest_point(&Point(first_coord)) {
-                    Closest::SinglePoint(p) => p.0,
-                    Closest::Intersection(p) => p.0,
-                    Closest::Indeterminate => {
-                        // I don't think this should happen, but let's try to lumber on if it does.
-                        // And assert so we know that we have to think harder about this.
-                        debug_assert!(false, "Only happens with empty or invalid geometries");
-                        continue;
-                    }
-                };
-            }
-
-            let new_final_distance = Euclidean.distance(&segment, final_coord);
-            if new_final_distance < distance_to_final {
-                distance_to_final = new_final_distance;
-                segment_idx_closest_to_final = segment_idx;
-                coord_closest_to_final = match segment.closest_point(&Point(final_coord)) {
-                    Closest::SinglePoint(p) => p.0,
-                    Closest::Intersection(p) => p.0,
-                    Closest::Indeterminate => {
-                        // I don't think this should happen, but let's try to lumber on if it does.
-                        // And assert so we know that we have to think harder about this.
-                        debug_assert!(false, "Only happens with empty or invalid geometries");
-                        continue;
-                    }
-                };
-            }
-        }
+        let (
+            (segment_idx_closest_to_first, coord_closest_to_first),
+            (segment_idx_closest_to_last, coord_closest_to_last),
+        ) = self.coords_near_endpoints(closest_to);
 
         let assemble = |starting_segment_idx: usize,
                         ending_segment_idx: usize,
@@ -124,7 +92,7 @@ impl SliceNearestFrechetBoundary for Polygon {
                     let tail = &exterior.0[0..ending_segment_idx];
                     let mut coords = head.to_vec();
                     coords[0] = starting_coord;
-                    coords.extend_from_slice(&tail);
+                    coords.extend_from_slice(tail);
                     coords.push(ending_coord);
                     coords
                 }
@@ -135,20 +103,107 @@ impl SliceNearestFrechetBoundary for Polygon {
 
         let front_half = assemble(
             segment_idx_closest_to_first,
-            segment_idx_closest_to_final,
+            segment_idx_closest_to_last,
             coord_closest_to_first,
-            coord_closest_to_final,
+            coord_closest_to_last,
         );
         let back_half = assemble(
-            segment_idx_closest_to_final,
+            segment_idx_closest_to_last,
             segment_idx_closest_to_first,
-            coord_closest_to_final,
+            coord_closest_to_last,
             coord_closest_to_first,
         );
         (front_half, back_half)
     }
 }
 
+impl SliceNearEndpoints for LineString {
+    fn coords_near_endpoints(&self, closest_to: &LineString) -> ((usize, Coord), (usize, Coord)) {
+        // Not sure if this will ever be an issue in practice
+        assert!(!closest_to.is_empty(), "we don't yet handle empty input");
+        assert!(!self.0.is_empty(), "we don't yet handle empty input");
+
+        let first_coord = *closest_to.0.first().expect("non-empty linestring");
+        let final_coord = *closest_to.0.last().expect("non-empty linestring");
+
+        let mut distance_to_first = f64::MAX;
+        let mut segment_idx_closest_to_first = 0;
+        let mut coord_closest_to_first = coord!(x: 0., y: 0.);
+
+        let mut distance_to_end = f64::MAX;
+        let mut segment_idx_closest_to_end = 0;
+        let mut coord_closest_to_end = coord!(x: 0., y: 0.);
+        for (segment_idx, segment) in self.lines().enumerate() {
+            let new_start_distance = Euclidean.distance(&segment, first_coord);
+            if new_start_distance < distance_to_first {
+                distance_to_first = new_start_distance;
+                segment_idx_closest_to_first = segment_idx;
+                coord_closest_to_first = match segment.closest_point(&Point(first_coord)) {
+                    Closest::SinglePoint(p) => p.0,
+                    Closest::Intersection(p) => p.0,
+                    Closest::Indeterminate => {
+                        // I don't think this should happen, but let's try to lumber on if it does.
+                        // And assert so we know that we have to think harder about this.
+                        debug_assert!(false, "Only happens with empty or invalid geometries");
+                        continue;
+                    }
+                };
+            }
+
+            let new_end_distance = Euclidean.distance(&segment, final_coord);
+            if new_end_distance < distance_to_end {
+                distance_to_end = new_end_distance;
+                segment_idx_closest_to_end = segment_idx;
+                coord_closest_to_end = match segment.closest_point(&Point(final_coord)) {
+                    Closest::SinglePoint(p) => p.0,
+                    Closest::Intersection(p) => p.0,
+                    Closest::Indeterminate => {
+                        // I don't think this should happen, but let's try to lumber on if it does.
+                        // And assert so we know that we have to think harder about this.
+                        debug_assert!(false, "Only happens with empty or invalid geometries");
+                        continue;
+                    }
+                };
+            }
+        }
+        (
+            (segment_idx_closest_to_first, coord_closest_to_first),
+            (segment_idx_closest_to_end, coord_closest_to_end),
+        )
+    }
+
+    type SliceType = LineString;
+    fn slice_near_endpoints(&self, closest_to: &LineString) -> LineString {
+        let (
+            (segment_idx_closest_to_first, coord_closest_to_first),
+            (segment_idx_closest_to_last, coord_closest_to_last),
+        ) = self.coords_near_endpoints(closest_to);
+
+        let mut coords = match segment_idx_closest_to_first.cmp(&segment_idx_closest_to_last) {
+            Ordering::Less => {
+                let mut coords =
+                    self.0[segment_idx_closest_to_first..=segment_idx_closest_to_last].to_vec();
+                coords[0] = coord_closest_to_first;
+                coords.push(coord_closest_to_last);
+                coords
+            }
+            Ordering::Equal => {
+                // can we consolidate this?
+                vec![coord_closest_to_first, coord_closest_to_last]
+            }
+            Ordering::Greater => {
+                let mut coords =
+                    self.0[segment_idx_closest_to_last..=segment_idx_closest_to_first].to_vec();
+                coords[0] = coord_closest_to_last;
+                coords.push(coord_closest_to_first);
+                coords
+            }
+        };
+        coords.dedup();
+        LineString::new(coords)
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
diff --git a/backend/src/lib.rs b/backend/src/lib.rs
index 5930fda8..4a13c461 100644
--- a/backend/src/lib.rs
+++ b/backend/src/lib.rs
@@ -3,8 +3,6 @@ extern crate anyhow;
 #[macro_use]
 extern crate log;
 
-use std::sync::Once;
-
 use self::boundary_stats::ContextData;
 use self::cells::Cell;
 pub use self::map_model::{
@@ -19,6 +17,7 @@ use crate::map_model::{Command, ProjectDetails};
 use geo::{Coord, LineString, Polygon};
 use geojson::{Feature, FeatureCollection, GeoJson, Geometry};
 use serde::Deserialize;
+use std::sync::Once;
 use wasm_bindgen::prelude::*;
 
 mod auto_boundaries;
@@ -362,22 +361,59 @@ impl LTN {
         self.after_main_road_edit()
     }
 
+    /// Takes a LineString feature
+    #[wasm_bindgen(js_name = reclassifyRoadsAlongLine)]
+    pub fn reclassify_roads_along_line(
+        &mut self,
+        input: JsValue,
+        is_main_road: bool,
+        add_to_undo_stack: bool,
+    ) -> Result<(), JsValue> {
+        let gj: Feature = serde_wasm_bindgen::from_value(input)?;
+        let mut line: LineString = gj.try_into().map_err(err_to_js)?;
+        debug_assert!(!line.0.is_empty());
+        self.map.mercator.to_mercator_in_place(&mut line);
+        self.map.reclassify_roads_along_line(
+            self.neighbourhood.as_ref().unwrap(),
+            line,
+            is_main_road,
+            add_to_undo_stack,
+        );
+
+        self.after_edit();
+        self.after_main_road_edit()
+    }
+
     pub fn undo(&mut self) -> Result<(), JsValue> {
         let maybe_cmd = self.map.undo();
-        self.after_edit();
-        if matches!(maybe_cmd, Some(Command::SetMainRoad(_, _))) {
-            return self.after_main_road_edit();
-        }
-        Ok(())
+        self.after_cmd(maybe_cmd)
     }
 
     pub fn redo(&mut self) -> Result<(), JsValue> {
         let maybe_cmd = self.map.redo();
+        self.after_cmd(maybe_cmd)
+    }
+
+    fn after_cmd(&mut self, cmd: Option<Command>) -> Result<(), JsValue> {
         self.after_edit();
-        if matches!(maybe_cmd, Some(Command::SetMainRoad(_, _))) {
-            return self.after_main_road_edit();
+        let Some(cmd) = cmd else { return Ok(()) };
+
+        let first_cmd = match &cmd {
+            Command::Multiple(cmds) => {
+                let Some(first_cmd) = cmds.first() else {
+                    debug_assert!(false, "Command::Multiple shouldn't be empty");
+                    return Ok(());
+                };
+                first_cmd
+            }
+            single_cmd => single_cmd,
+        };
+
+        if matches!(first_cmd, Command::SetMainRoad(_, _)) {
+            self.after_main_road_edit()
+        } else {
+            Ok(())
         }
-        Ok(())
     }
 
     #[wasm_bindgen(js_name = getShortcutsCrossingRoad)]
diff --git a/backend/src/map_model.rs b/backend/src/map_model.rs
index 30368588..cfc4ca84 100644
--- a/backend/src/map_model.rs
+++ b/backend/src/map_model.rs
@@ -1,16 +1,16 @@
 use crate::boundary_stats::{ContextData, PreparedContextData};
 use crate::geo_helpers::{
-    angle_of_pt_on_line, bearing_from_endpoint, buffer_aabb, diagonal_bearing,
+    aabb, angle_of_pt_on_line, bearing_from_endpoint, buffer_aabb, diagonal_bearing,
     invert_multi_polygon, limit_angle, linestring_intersection,
 };
 use crate::impact::Impact;
 use crate::neighbourhood::{NeighbourhoodBoundary, NeighbourhoodDefinition};
 use crate::route::RouterInput;
-use crate::{od::DemandModel, Router};
+use crate::{od::DemandModel, Neighbourhood, Router};
 use anyhow::Result;
 use geo::{
     line_measures::InterpolatableLine, Closest, ClosestPoint, Coord, Distance, Euclidean, Length,
-    LineLocatePoint, LineString, MultiPolygon, Point, Polygon,
+    LineLocatePoint, LineString, MultiPolygon, Point, Polygon, Simplify,
 };
 use geojson::{Feature, FeatureCollection, GeoJson, Geometry, JsonValue};
 use rstar::{primitives::GeomWithData, RTree, AABB};
@@ -484,6 +484,59 @@ impl MapModel {
         self.after_edited();
     }
 
+    fn roads_along_line(
+        &self,
+        neighbourhood: &Neighbourhood,
+        line_string: LineString,
+    ) -> Vec<RoadID> {
+        let line_string = line_string.simplify(&0.5);
+        let bbox = aabb(&line_string);
+        let buffered_bbox = buffer_aabb(bbox, 20.);
+        let road_iter = self
+            .closest_road
+            .locate_in_envelope_intersecting(&buffered_bbox)
+            .filter(|r| {
+                neighbourhood.interior_roads.contains(&r.data)
+                    || neighbourhood.main_roads.contains(&r.data)
+            })
+            .map(|road_node| self.get_r(road_node.data));
+
+        crate::geo_helpers::roads_along_line(road_iter, &line_string)
+    }
+
+    pub fn reclassify_roads_along_line(
+        &mut self,
+        neighbourhood: &Neighbourhood,
+        line_string: LineString,
+        is_main_road: bool,
+        add_to_undo_stack: bool,
+    ) {
+        let roads_along = self.roads_along_line(neighbourhood, line_string);
+        let cmds = roads_along
+            .iter()
+            .map(|r| Command::SetMainRoad(*r, is_main_road))
+            .collect();
+        let cmd = Command::Multiple(cmds);
+
+        let undo_cmd = self.do_edit(cmd);
+        if add_to_undo_stack {
+            if let Some(prev_cmd) = self.undo_stack.first() {
+                if prev_cmd == &undo_cmd {
+                    // Don't add no-op undo commands.
+                    // e.g. when re-classifying an area the same as it was already classified.
+                    //
+                    // I originally though we could bail out earlier, by seeing if any of
+                    // `roads_along` had changed, but then that would prevent us from *ever*
+                    // recording the undo action in the case of our incremental updates (see `add_to_undo_stack`)
+                    return;
+                }
+            }
+            self.undo_stack.push(undo_cmd);
+            self.redo_stack.clear();
+        }
+        self.after_edited();
+    }
+
     // Returns the command to undo this one
     fn do_edit(&mut self, cmd: Command) -> Command {
         match cmd {
@@ -1204,7 +1257,7 @@ impl TravelFlow {
     }
 }
 
-#[derive(Clone)]
+#[derive(Debug, Clone, PartialEq)]
 pub enum Command {
     SetModalFilter(RoadID, Option<ModalFilter>),
     SetDiagonalFilter(IntersectionID, Option<DiagonalFilter>),
diff --git a/web/assets/cursors/eraser.svg b/web/assets/cursors/eraser.svg
new file mode 100644
index 00000000..8476f195
--- /dev/null
+++ b/web/assets/cursors/eraser.svg
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   width="24"
+   height="24"
+   viewBox="0 0 24 24"
+   fill="none"
+   stroke="currentColor"
+   stroke-width="2"
+   stroke-linecap="round"
+   stroke-linejoin="round"
+   class="lucide lucide-eraser-icon lucide-eraser"
+   version="1.1"
+   id="svg3"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg">
+  <defs
+     id="defs3" />
+  <path
+     d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21"
+     id="path1" />
+  <path
+     d="M22 21H7"
+     id="path2" />
+  <path
+     d="m5 11 9 9"
+     id="path3" />
+  <path
+     style="fill:#ffffff;stroke:#ff533e;stroke-width:0.0194348;fill-opacity:1"
+     d="M 10.210757,14.752002 6.459622,11.000548 9.8160254,7.6426783 C 12.367029,5.0905596 13.239386,4.2512757 13.451467,4.1450647 c 0.349597,-0.1750803 0.789698,-0.1876196 1.091224,-0.031091 0.115256,0.059832 1.582424,1.4856537 3.265247,3.1732297 3.019567,3.0280936 3.057066,3.0684746 3.149916,3.3920116 0.07937,0.276581 0.08343,0.372957 0.02609,0.620542 -0.06661,0.287624 -0.13239,0.357763 -3.515539,3.748403 -1.896214,1.900412 -3.460904,3.455295 -3.477088,3.455295 -0.01619,0 -1.717438,-1.688155 -3.780562,-3.751453 z"
+     id="path4" />
+  <path
+     style="fill:#ffffff;fill-opacity:1;stroke:#ff533e;stroke-width:0.0194348"
+     d="M 5.321794,17.868809 C 4.1705931,16.713048 3.1848808,15.680805 3.131322,15.574936 3.077763,15.469067 3.0207033,15.236442 3.004522,15.057992 2.959692,14.563595 3.080751,14.37604 4.1030807,13.356012 l 0.8902675,-0.888263 3.7509032,3.751221 3.7509036,3.751222 H 9.9550205 7.4148864 Z"
+     id="path5" />
+</svg>
diff --git a/web/assets/cursors/paintbrush.svg b/web/assets/cursors/paintbrush.svg
new file mode 100644
index 00000000..a032771a
--- /dev/null
+++ b/web/assets/cursors/paintbrush.svg
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+   width="24"
+   height="24"
+   viewBox="0 0 24 24"
+   fill="none"
+   stroke="currentColor"
+   stroke-width="2"
+   stroke-linecap="round"
+   stroke-linejoin="round"
+   class="lucide lucide-paintbrush-icon lucide-paintbrush"
+   version="1.1"
+   id="svg3"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg">
+  <defs
+     id="defs3" />
+  <path
+     d="m14.622 17.897-10.68-2.913"
+     id="path1" />
+  <path
+     d="M18.376 2.622a1 1 0 1 1 3.002 3.002L17.36 9.643a.5.5 0 0 0 0 .707l.944.944a2.41 2.41 0 0 1 0 3.408l-.944.944a.5.5 0 0 1-.707 0L8.354 7.348a.5.5 0 0 1 0-.707l.944-.944a2.41 2.41 0 0 1 3.408 0l.944.944a.5.5 0 0 0 .707 0z"
+     id="path2" />
+  <path
+     d="M9 8c-1.804 2.71-3.97 3.46-6.583 3.948a.507.507 0 0 0-.302.819l7.32 8.883a1 1 0 0 0 1.185.204C12.735 20.405 16 16.792 16 15"
+     id="path3" />
+  <path
+     style="fill:#ffffff;stroke:#ff533e;stroke-width:0.00941713"
+     d="M 13.216364,10.769138 9.4441913,6.9961356 9.8268793,6.6205315 C 10.053748,6.3978626 10.26605,6.2149074 10.348288,6.1711993 c 0.393818,-0.2093087 0.914655,-0.2092623 1.30798,1.172e-4 0.09062,0.048241 0.356713,0.2875083 0.767422,0.6900596 0.345786,0.338918 0.691198,0.6557984 0.767583,0.7041789 0.216015,0.1368197 0.430512,0.2013581 0.722288,0.2173241 0.316089,0.017296 0.499892,-0.017578 0.768527,-0.1458202 0.194182,-0.092699 0.283143,-0.1774989 2.37486,-2.2637553 2.357603,-2.3514493 2.273297,-2.2768061 2.645598,-2.3423855 0.33614,-0.05921 0.694848,0.059949 0.950717,0.3158186 0.253238,0.2532377 0.374946,0.6150471 0.31719,0.9429318 -0.0673,0.3820937 0.01738,0.2862242 -2.343684,2.6533839 -2.086365,2.0917555 -2.171127,2.1806742 -2.263828,2.3748599 -0.127718,0.2675385 -0.162966,0.4524174 -0.14652,0.7685267 0.01524,0.292912 0.09451,0.540519 0.242982,0.758947 0.04844,0.07126 0.360914,0.406322 0.694399,0.744591 0.41818,0.424179 0.627762,0.657447 0.675382,0.751709 0.212723,0.421072 0.212671,0.891705 -1.46e-4,1.312073 -0.04338,0.08569 -0.207187,0.276887 -0.439097,0.512528 -0.203448,0.20672 -0.376992,0.375854 -0.385655,0.375854 -0.0087,0 -1.713228,-1.697851 -3.787922,-3.773003 z"
+     id="path4" />
+  <path
+     style="fill:#ffffff;stroke:#ff533e;stroke-width:0.00941713;fill-opacity:1"
+     d="M 9.349188,15.407794 4.525255,14.090285 3.9850003,13.435502 3.4447455,12.780718 3.9569969,12.649282 C 6.0202821,12.119873 7.4294884,11.334333 8.7020764,10.004212 8.8652116,9.8337017 9.0246733,9.6590433 9.0564355,9.6160824 l 0.05775,-0.078111 2.9032955,2.9031996 2.903295,2.9032 -0.06256,0.166083 c -0.122687,0.325724 -0.617409,1.223308 -0.670589,1.216662 -0.008,-9.97e-4 -2.18528,-0.594692 -4.8384428,-1.319322 z"
+     id="path5" />
+  <path
+     style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0.00941713;stroke-opacity:1"
+     d="M 8.4622824,18.869673 C 7.5370363,17.746854 6.7832695,16.824931 6.7872451,16.820955 c 0.00525,-0.0052 5.8629439,1.583356 6.0455299,1.639545 0.04062,0.0125 -1.405633,1.450684 -1.775827,1.765924 -0.389041,0.331289 -0.845683,0.684738 -0.884654,0.684738 -0.01526,0 -0.7847654,-0.91867 -1.7100116,-2.041489 z"
+     id="path6" />
+</svg>
diff --git a/web/package-lock.json b/web/package-lock.json
index 0c634c82..f910ba60 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -10,6 +10,7 @@
       "dependencies": {
         "@picocss/pico": "^2.0.6",
         "@turf/along": "^7.1",
+        "@turf/distance": "^7.2.0",
         "lucide-svelte": "^0.475.0",
         "route-snapper": "^0.4.9",
         "route-snapper-ts": "^0.0.8",
@@ -1495,20 +1496,6 @@
         "url": "https://opencollective.com/turf"
       }
     },
-    "node_modules/@turf/along/node_modules/@turf/distance": {
-      "version": "7.1.0",
-      "resolved": "https://registry.npmjs.org/@turf/distance/-/distance-7.1.0.tgz",
-      "integrity": "sha512-hhNHhxCHB3ddzAGCNY4BtE29OZh+DAJPvUapQz+wOjISnlwvMcwLKvslgHWSYF536QDVe/93FEU2q67+CsZTPA==",
-      "dependencies": {
-        "@turf/helpers": "^7.1.0",
-        "@turf/invariant": "^7.1.0",
-        "@types/geojson": "^7946.0.10",
-        "tslib": "^2.6.2"
-      },
-      "funding": {
-        "url": "https://opencollective.com/turf"
-      }
-    },
     "node_modules/@turf/along/node_modules/@turf/helpers": {
       "version": "7.1.0",
       "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.1.0.tgz",
@@ -1661,12 +1648,42 @@
       }
     },
     "node_modules/@turf/distance": {
-      "version": "6.5.0",
-      "resolved": "https://registry.npmjs.org/@turf/distance/-/distance-6.5.0.tgz",
-      "integrity": "sha512-xzykSLfoURec5qvQJcfifw/1mJa+5UwByZZ5TZ8iaqjGYN0vomhV9aiSLeYdUGtYRESZ+DYC/OzY+4RclZYgMg==",
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/@turf/distance/-/distance-7.2.0.tgz",
+      "integrity": "sha512-HBjjXIgEcD/wJYjv7/6OZj5yoky2oUvTtVeIAqO3lL80XRvoYmVg6vkOIu6NswkerwLDDNT9kl7+BFLJoHbh6Q==",
+      "license": "MIT",
       "dependencies": {
-        "@turf/helpers": "^6.5.0",
-        "@turf/invariant": "^6.5.0"
+        "@turf/helpers": "^7.2.0",
+        "@turf/invariant": "^7.2.0",
+        "@types/geojson": "^7946.0.10",
+        "tslib": "^2.8.1"
+      },
+      "funding": {
+        "url": "https://opencollective.com/turf"
+      }
+    },
+    "node_modules/@turf/distance/node_modules/@turf/helpers": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.2.0.tgz",
+      "integrity": "sha512-cXo7bKNZoa7aC7ydLmUR02oB3IgDe7MxiPuRz3cCtYQHn+BJ6h1tihmamYDWWUlPHgSNF0i3ATc4WmDECZafKw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/geojson": "^7946.0.10",
+        "tslib": "^2.8.1"
+      },
+      "funding": {
+        "url": "https://opencollective.com/turf"
+      }
+    },
+    "node_modules/@turf/distance/node_modules/@turf/invariant": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-7.2.0.tgz",
+      "integrity": "sha512-kV4u8e7Gkpq+kPbAKNC21CmyrXzlbBgFjO1PhrHPgEdNqXqDawoZ3i6ivE3ULJj2rSesCjduUaC/wyvH/sNr2Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@turf/helpers": "^7.2.0",
+        "@types/geojson": "^7946.0.10",
+        "tslib": "^2.8.1"
       },
       "funding": {
         "url": "https://opencollective.com/turf"
@@ -1742,6 +1759,19 @@
         "url": "https://opencollective.com/turf"
       }
     },
+    "node_modules/@turf/length/node_modules/@turf/distance": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmjs.org/@turf/distance/-/distance-6.5.0.tgz",
+      "integrity": "sha512-xzykSLfoURec5qvQJcfifw/1mJa+5UwByZZ5TZ8iaqjGYN0vomhV9aiSLeYdUGtYRESZ+DYC/OzY+4RclZYgMg==",
+      "license": "MIT",
+      "dependencies": {
+        "@turf/helpers": "^6.5.0",
+        "@turf/invariant": "^6.5.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/turf"
+      }
+    },
     "node_modules/@turf/line-intersect": {
       "version": "6.5.0",
       "resolved": "https://registry.npmjs.org/@turf/line-intersect/-/line-intersect-6.5.0.tgz",
@@ -1843,6 +1873,19 @@
         "url": "https://opencollective.com/turf"
       }
     },
+    "node_modules/@turf/nearest-point-on-line/node_modules/@turf/distance": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmjs.org/@turf/distance/-/distance-6.5.0.tgz",
+      "integrity": "sha512-xzykSLfoURec5qvQJcfifw/1mJa+5UwByZZ5TZ8iaqjGYN0vomhV9aiSLeYdUGtYRESZ+DYC/OzY+4RclZYgMg==",
+      "license": "MIT",
+      "dependencies": {
+        "@turf/helpers": "^6.5.0",
+        "@turf/invariant": "^6.5.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/turf"
+      }
+    },
     "node_modules/@turf/square": {
       "version": "6.5.0",
       "resolved": "https://registry.npmjs.org/@turf/square/-/square-6.5.0.tgz",
@@ -1855,6 +1898,19 @@
         "url": "https://opencollective.com/turf"
       }
     },
+    "node_modules/@turf/square/node_modules/@turf/distance": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmjs.org/@turf/distance/-/distance-6.5.0.tgz",
+      "integrity": "sha512-xzykSLfoURec5qvQJcfifw/1mJa+5UwByZZ5TZ8iaqjGYN0vomhV9aiSLeYdUGtYRESZ+DYC/OzY+4RclZYgMg==",
+      "license": "MIT",
+      "dependencies": {
+        "@turf/helpers": "^6.5.0",
+        "@turf/invariant": "^6.5.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/turf"
+      }
+    },
     "node_modules/@turf/truncate": {
       "version": "6.5.0",
       "resolved": "https://registry.npmjs.org/@turf/truncate/-/truncate-6.5.0.tgz",
diff --git a/web/package.json b/web/package.json
index 579b5105..242181f3 100644
--- a/web/package.json
+++ b/web/package.json
@@ -34,6 +34,7 @@
   "dependencies": {
     "@picocss/pico": "^2.0.6",
     "@turf/along": "^7.1",
+    "@turf/distance": "^7.2.0",
     "lucide-svelte": "^0.475.0",
     "route-snapper": "^0.4.9",
     "route-snapper-ts": "^0.0.8",
diff --git a/web/src/DebugNeighbourhoodMode.svelte b/web/src/DebugNeighbourhoodMode.svelte
index 1491ca81..f5f4fa53 100644
--- a/web/src/DebugNeighbourhoodMode.svelte
+++ b/web/src/DebugNeighbourhoodMode.svelte
@@ -119,7 +119,8 @@
       </NeighbourhoodRoadLayer>
     </RenderNeighbourhood>
 
-    <ModalFilterLayer>
+    <ModalFilterLayer interactive={true}>
+      <!-- Note: This popup is currently broken (it was before this commit too). -->
       <Popup openOn="hover" let:props>
         <PropertiesTable properties={props} />
       </Popup>
diff --git a/web/src/ImpactDetailMode.svelte b/web/src/ImpactDetailMode.svelte
index d837b754..3a271b5b 100644
--- a/web/src/ImpactDetailMode.svelte
+++ b/web/src/ImpactDetailMode.svelte
@@ -162,6 +162,6 @@
       <DotMarker lngLat={endPos(idx)}>B</DotMarker>
     {/if}
 
-    <ModalFilterLayer />
+    <ModalFilterLayer interactive={false} />
   </div>
 </SplitComponent>
diff --git a/web/src/ImpactOneDestinationMode.svelte b/web/src/ImpactOneDestinationMode.svelte
index 0aa2500b..d53dae35 100644
--- a/web/src/ImpactOneDestinationMode.svelte
+++ b/web/src/ImpactOneDestinationMode.svelte
@@ -193,7 +193,7 @@
       />
     </GeoJSON>
 
-    <ModalFilterLayer />
+    <ModalFilterLayer interactive={false} />
 
     <DotMarker bind:lngLat={$oneDestination} draggable>X</DotMarker>
   </div>
diff --git a/web/src/PickNeighbourhoodMode.svelte b/web/src/PickNeighbourhoodMode.svelte
index c9acd48c..71a54e8b 100644
--- a/web/src/PickNeighbourhoodMode.svelte
+++ b/web/src/PickNeighbourhoodMode.svelte
@@ -332,7 +332,7 @@
       </FillLayer>
     </GeoJSON>
 
-    <ModalFilterLayer />
+    <ModalFilterLayer interactive={false} />
   </div>
 </SplitComponent>
 
diff --git a/web/src/PredictImpactMode.svelte b/web/src/PredictImpactMode.svelte
index 4f338fab..a4173f80 100644
--- a/web/src/PredictImpactMode.svelte
+++ b/web/src/PredictImpactMode.svelte
@@ -209,6 +209,6 @@
       </LineLayer>
     </GeoJSON>
 
-    <ModalFilterLayer />
+    <ModalFilterLayer interactive={false} />
   </div>
 </SplitComponent>
diff --git a/web/src/RouteMode.svelte b/web/src/RouteMode.svelte
index 2db76bea..bee75479 100644
--- a/web/src/RouteMode.svelte
+++ b/web/src/RouteMode.svelte
@@ -135,7 +135,7 @@
       </RenderNeighbourhood>
     {/if}
 
-    <ModalFilterLayer />
+    <ModalFilterLayer interactive={false} />
 
     <GeoJSON data={gj}>
       <LineLayer
diff --git a/web/src/ViewShortcutsMode.svelte b/web/src/ViewShortcutsMode.svelte
index 68e74f8f..2555d3d1 100644
--- a/web/src/ViewShortcutsMode.svelte
+++ b/web/src/ViewShortcutsMode.svelte
@@ -170,6 +170,6 @@
       </DotMarker>
     {/if}
 
-    <ModalFilterLayer />
+    <ModalFilterLayer interactive={false} />
   </div>
 </SplitComponent>
diff --git a/web/src/edit/FreehandLine.svelte b/web/src/edit/FreehandLine.svelte
index 3400973d..5f6e41a4 100644
--- a/web/src/edit/FreehandLine.svelte
+++ b/web/src/edit/FreehandLine.svelte
@@ -1,4 +1,5 @@
 <script lang="ts">
+  import turfDistance from "@turf/distance";
   import type { Feature, LineString } from "geojson";
   import type { Map, MapMouseEvent } from "maplibre-gl";
   import { createEventDispatcher, onDestroy } from "svelte";
@@ -10,6 +11,7 @@
 
   const dispatch = createEventDispatcher<{
     done: Feature<LineString> | null;
+    progress: Feature<LineString>;
   }>();
 
   map.on("dragstart", onDragStart);
@@ -37,17 +39,33 @@
 
   function onMouseMove(e: MapMouseEvent) {
     if (line) {
-      // TODO Simplify
-      line.geometry.coordinates.push(e.lngLat.toArray());
+      let next = e.lngLat.toArray();
+      let prev = line.geometry.coordinates.at(-1);
+
+      if (prev) {
+        let distanceMeters = turfDistance(prev, next) * 1000;
+        if (distanceMeters < 0.5) {
+          // Skip if the distance is too small, this avoids redraw.
+          return;
+        }
+      }
+
+      line.geometry.coordinates.push(next);
       line = line;
+      if (line.geometry.coordinates.length % 10 == 0) {
+        dispatch("progress", line);
+      }
     }
   }
 
   function onMouseUp() {
-    if (line) {
+    if (!line || line.geometry.coordinates.length == 0) {
+      dispatch("done", null);
+    } else {
       dispatch("done", line);
-      line = null;
     }
+    line = null;
+    map.dragPan.enable();
   }
 </script>
 
diff --git a/web/src/edit/NeighbourhoodMode.svelte b/web/src/edit/NeighbourhoodMode.svelte
index a093aff4..d4abe487 100644
--- a/web/src/edit/NeighbourhoodMode.svelte
+++ b/web/src/edit/NeighbourhoodMode.svelte
@@ -1,6 +1,6 @@
 <script lang="ts">
   import type { Feature, FeatureCollection, LineString } from "geojson";
-  import { PaintbrushVertical, Redo, Undo } from "lucide-svelte";
+  import { Eraser, Paintbrush, Pointer, Redo, Undo } from "lucide-svelte";
   import type { LngLat, MapMouseEvent } from "maplibre-gl";
   import { onDestroy } from "svelte";
   import {
@@ -70,7 +70,7 @@
           { road: number; name: string }
         >;
       }
-    | { kind: "main-roads" };
+    | { kind: "main-roads"; freehand: boolean; isMain: boolean };
   function startTurnRestrictionAction(): Action {
     return {
       kind: "turn_restriction",
@@ -90,6 +90,25 @@
     $map!.doubleClickZoom.enable();
   }
 
+  $: {
+    if (
+      (action.kind == "filter" && action.freehand) ||
+      (action.kind == "main-roads" && action.freehand && action.isMain)
+    ) {
+      $map!.getCanvas().style.cursor =
+        "url(./assets/cursors/paintbrush.svg) 8 22, cell";
+    } else if (
+      action.kind == "main-roads" &&
+      action.freehand &&
+      !action.isMain
+    ) {
+      $map!.getCanvas().style.cursor =
+        "url(./assets/cursors/eraser.svg) 8 22, crosshair";
+    } else {
+      $map!.getCanvas().style.cursor = "";
+    }
+  }
+
   let settingFilterType = false;
   let undoLength = 0;
   let redoLength = 0;
@@ -128,7 +147,7 @@
     } else if (action.kind == "oneway") {
       $backend!.toggleTravelFlow(f.properties!.road);
       $mutationCounter++;
-    } else if (action.kind == "main-roads") {
+    } else if (action.kind == "main-roads" && !action.freehand) {
       $backend!.toggleMainRoad(f.properties!.road);
       $mutationCounter++;
     } else if (action.kind == "turn_restriction") {
@@ -226,7 +245,7 @@
       action = startTurnRestrictionAction();
     }
     if (e.key == "4") {
-      action = { kind: "main-roads" };
+      action = { kind: "main-roads", freehand: false, isMain: false };
     }
   }
 
@@ -239,15 +258,29 @@
     $mutationCounter++;
   }
 
-  function gotFreehandLine(e: CustomEvent<Feature<LineString> | null>) {
+  function paintedModalFiltersLine(e: CustomEvent<Feature<LineString> | null>) {
     let f = e.detail;
     if (f) {
       $backend!.addManyModalFilters(f, $currentFilterType);
       $mutationCounter++;
     }
-
     action = { kind: "filter", freehand: false };
   }
+
+  function paintedRoadClassificationsLine(
+    e: CustomEvent<Feature<LineString> | null>,
+    addToUndoStack: boolean,
+  ) {
+    if (action.kind != "main-roads") {
+      console.assert(false, "cant paint line unless in main-roads mode");
+      return;
+    }
+    let f = e.detail;
+    if (f) {
+      $backend!.reclassifyRoadsAlongLine(f, action.isMain, addToUndoStack);
+      $mutationCounter++;
+    }
+  }
 </script>
 
 <svelte:window on:keydown={onKeyDown} />
@@ -388,7 +421,8 @@
           />
         </button>
         <button
-          on:click={() => (action = { kind: "main-roads" })}
+          on:click={() =>
+            (action = { kind: "main-roads", freehand: false, isMain: false })}
           disabled={action.kind == "main-roads"}
           class="icon-btn"
           class:active={action.kind == "main-roads"}
@@ -447,7 +481,7 @@
             data-tooltip="Add many modal filters along a line"
           >
             <div style="display: flex; align-items: center; gap: 8px;">
-              <PaintbrushVertical />
+              <Paintbrush />
               <span> Add along a line </span>
             </div>
           </button>
@@ -479,6 +513,50 @@
           Main roads are typically better suited to support higher levels of
           traffic than neighbourhood roads.
         </p>
+        <div
+          class="classification-buttons"
+          style="display: flex; flex-direction: column; gap: 8px; justify-content: left;"
+        >
+          <button
+            on:click={() => {
+              action = { kind: "main-roads", freehand: false, isMain: true };
+            }}
+            class:active={!action.freehand}
+            class:outline={action.freehand}
+            data-tooltip="Click a road to reclassify it"
+          >
+            <div style="display: flex; align-items: center; gap: 8px;">
+              <Pointer />
+              <span>Toggle segment or pan map</span>
+            </div>
+          </button>
+          <button
+            on:click={() => {
+              action = { kind: "main-roads", freehand: true, isMain: true };
+            }}
+            class:active={action.freehand && action.isMain}
+            class:outline={!(action.freehand && action.isMain)}
+            data-tooltip="Reclassify multiple roads by drawing a line along them"
+          >
+            <div style="display: flex; align-items: center; gap: 8px;">
+              <Paintbrush />
+              <span>Mark as main along a line</span>
+            </div>
+          </button>
+          <button
+            on:click={() => {
+              action = { kind: "main-roads", freehand: true, isMain: false };
+            }}
+            class:active={action.freehand && !action.isMain}
+            class:outline={!(action.freehand && !action.isMain)}
+            data-tooltip="Reclassify multiple roads by drawing a line along them"
+          >
+            <div style="display: flex; align-items: center; gap: 8px;">
+              <Eraser />
+              <span>Erase main classification</span>
+            </div>
+          </button>
+        </div>
       {/if}
     </div>
 
@@ -533,9 +611,9 @@
       <OneWayLayer />
 
       <NeighbourhoodRoadLayer
-        interactive={action.kind == "filter" ||
+        interactive={(action.kind == "filter" && !action.freehand) ||
           action.kind == "oneway" ||
-          action.kind == "main-roads" ||
+          (action.kind == "main-roads" && !action.freehand) ||
           (action.kind == "turn_restriction" && action.from_road_id == null)}
         {onClickLine}
       >
@@ -564,7 +642,7 @@
               </div>
             {:else if action.kind == "oneway"}
               <p>Click to change direction</p>
-            {:else if action.kind == "main-roads"}
+            {:else if action.kind == "main-roads" && !action.freehand}
               <p>Click to designate a main road or not</p>
             {:else if action.kind == "turn_restriction"}
               <p>Click to create a turn restriction from here</p>
@@ -586,6 +664,7 @@
     <ModalFilterLayer
       onClickModalFilter={deleteModalFilter}
       onClickTurnRestriction={deleteTurnRestriction}
+      interactive={action.kind == "filter"}
     >
       <div slot="modal-filter">
         <Popup openOn="hover">Click to delete filter</Popup>
@@ -596,7 +675,13 @@
     </ModalFilterLayer>
 
     {#if action.kind == "filter" && action.freehand}
-      <FreehandLine map={notNull($map)} on:done={gotFreehandLine} />
+      <FreehandLine map={notNull($map)} on:done={paintedModalFiltersLine} />
+    {:else if action.kind == "main-roads" && action.freehand}
+      <FreehandLine
+        map={notNull($map)}
+        on:done={(e) => paintedRoadClassificationsLine(e, true)}
+        on:progress={(e) => paintedRoadClassificationsLine(e, false)}
+      />
     {/if}
 
     {#if action.kind == "turn_restriction"}
@@ -653,4 +738,7 @@
     /* picocss disabled override */
     opacity: 1;
   }
+  .classification-buttons {
+    width: fit-content;
+  }
 </style>
diff --git a/web/src/layers/ModalFilterLayer.svelte b/web/src/layers/ModalFilterLayer.svelte
index f7dd9345..7f90b9f3 100644
--- a/web/src/layers/ModalFilterLayer.svelte
+++ b/web/src/layers/ModalFilterLayer.svelte
@@ -18,6 +18,8 @@
     e: CustomEvent<LayerClickInfo>,
   ) => void = () => {};
 
+  export let interactive: boolean;
+
   let minzoom = 13;
   // TODO Runes would make this so nicer. The > 0 part is a hack...
   $: gj =
@@ -27,6 +29,7 @@
 <GeoJSON data={gj} generateId>
   <SymbolLayer
     {...layerId("modal-filters")}
+    {interactive}
     filter={[
       "all",
       ["!=", ["get", "filter_kind"], "diagonal_filter"],
diff --git a/web/src/wasm.ts b/web/src/wasm.ts
index 4ac56d1b..04e0809f 100644
--- a/web/src/wasm.ts
+++ b/web/src/wasm.ts
@@ -198,6 +198,14 @@ export class Backend {
     this.inner.toggleMainRoad(road);
   }
 
+  reclassifyRoadsAlongLine(
+    line: Feature<LineString>,
+    isMain: boolean,
+    addToUndoStack: boolean,
+  ) {
+    this.inner.reclassifyRoadsAlongLine(line, isMain, addToUndoStack);
+  }
+
   addTurnRestriction(from_road: number, to_road: number) {
     this.inner.addTurnRestriction(from_road, to_road);
   }


Reviews:
user: login: dabreegster
body: I love how the effects of this can be seen immediately as you draw. Very very nice!
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-02T22:16:55Z
user: login: michaelkirk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T22:25:24Z
user: login: michaelkirk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T22:34:43Z
user: login: michaelkirk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T22:37:09Z
user: login: michaelkirk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T23:37:04Z
user: login: dabreegster
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-03T10:01:04Z
user: login: michaelkirk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T17:45:08Z
user: login: JosiahParry
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-04T18:34:18Z
user: login: michaelkirk
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T20:44:29Z

Review comments:
diff_hunk: @@ -0,0 +1,237 @@
+use crate::geo_helpers::slice_nearest_boundary::SliceNearEndpoints;
+use crate::{Road, RoadID};
+use geo::line_measures::{Densifiable, LengthMeasurable};
+use geo::{Euclidean, HausdorffDistance, LineString};
+use std::collections::BTreeSet;
+
+pub fn roads_along_line<'a>(
+    roads: impl Iterator<Item = &'a Road>,
+    line_string: &LineString,
+) -> Vec<RoadID> {
+    let mut road_likelihoods = vec![];
+
+    for road in roads {
+        // Higher score means higher likelihood that the road lies along the drawn line.
+        let score = {
+            // Hausdorff distance measures a type of linestring similarity:
+            //   1. Get the set of shortest distances between all points on the two linestrings.
+            //   2. The minimum of those shortest distances would be the classic "Distance" calculation between linestrings,
+            //      though it's only computed between nodes.
+            //   3. Whereas the longest of those shortest distances is the "Hausdorff Distance".
+
+            // The line we draw will usually be much longer than the individual road segments we're
+            // matching against. If we were to consider the entire drawn line, most of it would be
+            // very far (in hausdorff terms) from any individual segment.
+            // So we trim the drawn line back near the endpoints of the segment we're considering.
+            //
+            // Well matched roads, lying along the drawn line, will tend to be about as long as
+            // the trimmed drawn line.
+            // Whereas perpendicular roads tend to have a very short trimmed drawn line, and should
+            // be punished.
+            let trimmed_line_string = line_string.slice_near_endpoints(&road.linestring);
+            let trimmed_line_length = trimmed_line_string.length(&Euclidean);
+            let road_length = road.linestring.length(&Euclidean);
+            let trimming_ratio = ((road_length - trimmed_line_length) / trimmed_line_length).abs();
+            let trimming_score = 1.0 / (1.0 + trimming_ratio);
+
+            // Hausdorff distance only computes distance between nodes, so we need to make sure our
path: backend/src/geo_helpers/roads_along_line.rs
user: login: dabreegster
body: When I've tried to use hausdorff distance before, I think I've gotten stuck / confused by this point. This is a useful workaround!
created_at: 2025-04-02T22:08:13Z
author_association: COLLABORATOR
path: backend/src/geo_helpers/roads_along_line.rs
user: login: dabreegster
body: Just want to say thank you for commenting this so thoroughly. These heuristics couldn't have been easy to come up with, but the end result feels super fluid to use, and it's not hard to understand.
created_at: 2025-04-02T22:10:46Z
author_association: COLLABORATOR
path: backend/src/geo_helpers/slice_nearest_boundary.rs
user: login: dabreegster
body: I'm sure you're thinking about this already, but do you think this is general enough to be useful in `geo`?
created_at: 2025-04-02T22:11:54Z
author_association: COLLABORATOR
diff_hunk: @@ -90,6 +90,25 @@
     $map!.doubleClickZoom.enable();
   }
 
+  $: {
+    if (
+      (action.kind == "filter" && action.freehand) ||
+      (action.kind == "main-roads" && action.freehand && action.isMain)
+    ) {
+      $map!.getCanvas().style.cursor =
+        "url(./assets/cursors/paintbrush.svg) 8 22, cell";
path: web/src/edit/NeighbourhoodMode.svelte
user: login: dabreegster
body: I have a very vague suspicion the URL may not work properly when deployed, but let's try and see
created_at: 2025-04-02T22:15:33Z
author_association: COLLABORATOR
diff_hunk: @@ -90,6 +90,25 @@
     $map!.doubleClickZoom.enable();
   }
 
+  $: {
+    if (
+      (action.kind == "filter" && action.freehand) ||
+      (action.kind == "main-roads" && action.freehand && action.isMain)
+    ) {
+      $map!.getCanvas().style.cursor =
+        "url(./assets/cursors/paintbrush.svg) 8 22, cell";
path: web/src/edit/NeighbourhoodMode.svelte
user: login: michaelkirk
body: oh yeah, whoops! I've seen the proper pattern elsewhere. I just forgot!
created_at: 2025-04-02T22:25:24Z
author_association: CONTRIBUTOR
path: backend/src/geo_helpers/slice_nearest_boundary.rs
user: login: michaelkirk
body: Possibly. Honestly, this is my first line matching project, so I'm learning as I go. I've definitely made some mistakes. 

For example, with the earlier "perimeter" classification logic I was using frechet distance. But because some of our lines can be backwards, I was checking both forwards and backwards frechet distance which... I'm pretty sure is effectively just the same thing as hausdorff distance, which doesn't care about line direction.

We've had some people ask on discord about line matching algorithms, so I think it might be nice to include more in geo. I'm sort of surprised that there's so much fiddling that needs to be done. I'm half way hoping that I have a facepalm moment and realize that this process can be much simpler than I've made it.
created_at: 2025-04-02T22:34:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -90,6 +90,25 @@
     $map!.doubleClickZoom.enable();
   }
 
+  $: {
+    if (
+      (action.kind == "filter" && action.freehand) ||
+      (action.kind == "main-roads" && action.freehand && action.isMain)
+    ) {
+      $map!.getCanvas().style.cursor =
+        "url(./assets/cursors/paintbrush.svg) 8 22, cell";
path: web/src/edit/NeighbourhoodMode.svelte
user: login: michaelkirk
body: I meant to call out these icons. Partly it's because they're cute, but primarily: I HATE WHEN MAPS DISABLE PAN. but I've done just that here. The icons hopefully at least communicate why you can't pan the map.
created_at: 2025-04-02T22:37:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -90,6 +90,25 @@
     $map!.doubleClickZoom.enable();
   }
 
+  $: {
+    if (
+      (action.kind == "filter" && action.freehand) ||
+      (action.kind == "main-roads" && action.freehand && action.isMain)
+    ) {
+      $map!.getCanvas().style.cursor =
+        "url(./assets/cursors/paintbrush.svg) 8 22, cell";
path: web/src/edit/NeighbourhoodMode.svelte
user: login: michaelkirk
body: Fixed in https://github.com/a-b-street/ltn/pull/297
created_at: 2025-04-02T23:37:04Z
author_association: CONTRIBUTOR
path: backend/src/geo_helpers/slice_nearest_boundary.rs
user: login: dabreegster
body: I think it really is a hard problem. https://github.com/JosiahParry/anime, https://github.com/nptscot/match_linestrings are some prior art; I think the approach here might be more robust
created_at: 2025-04-03T10:01:04Z
author_association: COLLABORATOR
path: backend/src/geo_helpers/slice_nearest_boundary.rs
user: login: michaelkirk
body: Oh wow, I should have asked sooner so that I could crib more. 

I shouldn't be surprised that the original contributor of our Hausdorff implementation (@josiahparry) has a whole line matching framework. 😆 
created_at: 2025-04-04T17:45:08Z
author_association: CONTRIBUTOR
path: backend/src/geo_helpers/slice_nearest_boundary.rs
user: login: JosiahParry
body: Would love to discuss the anime approach a bit more thoroughly. I don't have enough bandwidth to really clean it up lately but I do think it is really novel and can provide some good guidance on matching criteria. 

The "novel" aspect of it is that it doesn't compare whole line strings. Instead it compares the component line parts. This is particularly useful when when the two lines overlap but extend beyond each other. 
created_at: 2025-04-04T18:34:18Z
author_association: NONE
path: backend/src/geo_helpers/slice_nearest_boundary.rs
user: login: michaelkirk
body: That sounds similar to what I'm doing with the `SliceNearEndpoints` trait.

https://github.com/a-b-street/ltn/pull/293/files#diff-74296b5d5d23edf00e6fd638933f375aba2c2073c368d5c784b59a1f9d536a5aR120

There's some nuance to the approach - in particular, when you 'overtrim' a perpendicular(ish) segment, which I've tried to account for elsewhere in this PR.

But overall, it sounds like we may have come to some similar conclusions.
created_at: 2025-04-04T20:44:28Z
author_association: CONTRIBUTOR

PR comments:
user: login: michaelkirk
created_at: 2025-04-02T02:53:09Z
body: Whoops! didn't mean to open this PR yet, still doing some self review and testing.
author_association: CONTRIBUTOR
user: login: michaelkirk
created_at: 2025-04-02T18:33:36Z
body: Ok, this is ready for review! Please take a look @dabreegster 
author_association: CONTRIBUTOR