Skip to main content

symbios_tensor/
rationalize.rs

1//! Graph rationalization: straighten and smooth the road network.
2//!
3//! After the tracer produces a raw network of many small segments, this pass
4//! rewrites the graph into cleaner geometry:
5//!
6//! 1. **Global Laplacian elevation smoothing** averages node heights across the
7//!    entire graph, producing roads that bridge dips and cut through bumps.
8//!    A maximum grade clamp limits slope between adjacent nodes.
9//! 2. **Artery extraction** traces continuous paths *through* intersections by
10//!    forward-vector alignment, allowing global straightening of avenues.
11//! 3. **Ramer-Douglas-Peucker** decimation removes unnecessary intermediate
12//!    points, straightening nearly-collinear runs.
13//! 4. **Fillet corners** replaces sharp bends with smooth quadratic Bézier arcs,
14//!    giving the network a civil-engineered look.
15//!
16//! The result is a graph whose geometry is already presentation-ready — the 3D
17//! mesher can simply extrude edges without additional spline smoothing.
18
19use glam::Vec2;
20use serde::{Deserialize, Serialize};
21use symbios_ground::HeightMap;
22
23use crate::geometry::closest_point_on_segment;
24use crate::graph::{EdgeId, NodeId, RoadGraph, RoadType};
25use crate::topology::{
26    compute_active_degrees, extract_arteries, extract_chains, extract_chains_any_type,
27};
28
29/// Configuration for the graph rationalization pass.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct RationalizeConfig {
32    /// Master toggle.
33    pub enabled: bool,
34    /// RDP tolerance in world units — how aggressively to straighten.
35    pub rdp_tolerance: f32,
36    /// Fillet radius for major (contour-following) roads.
37    pub major_fillet_radius: f32,
38    /// Fillet radius for minor (gradient-following) roads.
39    pub minor_fillet_radius: f32,
40    /// Number of line segments used to approximate each fillet arc.
41    pub fillet_segments: u32,
42    /// Number of global Laplacian smoothing passes applied to the entire
43    /// road graph's elevation profile before chain extraction. Higher values
44    /// produce smoother roads that bridge over dips and cut through bumps,
45    /// giving a civil-engineered look. 0 disables.
46    pub elevation_smooth_passes: u32,
47    /// Maximum allowed slope (grade) between adjacent nodes, expressed as a
48    /// fraction (e.g. 0.15 = 15% grade). 0.0 disables clamping.
49    pub max_grade: f32,
50    /// Tolerance (world-space elevation units) for early-terminating the
51    /// elevation-smoothing and grade-clamping passes when they converge
52    /// before reaching the configured maximum. The pass exits as soon as
53    /// the largest single-node delta falls below this value.
54    /// Set to `0.0` to always run the full pass count (bit-identical
55    /// pre-#63 behaviour). Default: `1e-2` (1 cm — far below civil
56    /// engineering precision and well-suited to typical city inputs).
57    pub convergence_tolerance: f32,
58}
59
60impl Default for RationalizeConfig {
61    fn default() -> Self {
62        Self {
63            enabled: true,
64            rdp_tolerance: 2.0,
65            major_fillet_radius: 20.0,
66            minor_fillet_radius: 10.0,
67            fillet_segments: 6,
68            elevation_smooth_passes: 10,
69            max_grade: 0.15,
70            convergence_tolerance: 1e-2,
71        }
72    }
73}
74
75// ---------------------------------------------------------------------------
76// Public API
77// ---------------------------------------------------------------------------
78
79/// Unifies road types along topological chains (degree-2 paths between
80/// intersections) so that each chain has a single, consistent [`RoadType`].
81///
82/// When major and minor traces collide at T-junctions, the resulting path
83/// can alternate between types. This shatters arteries during smoothing
84/// because `extract_arteries` only follows same-type edges. By overwriting
85/// every edge in a chain to the majority type, we guarantee that each
86/// inter-intersection stretch is a single unbroken type.
87pub fn unify_road_types(graph: &mut RoadGraph) {
88    let degrees = compute_active_degrees(graph);
89    let chains = extract_chains_any_type(graph, &degrees);
90
91    for chain in &chains {
92        // Tally total length by road type.
93        let mut major_len: f32 = 0.0;
94        let mut minor_len: f32 = 0.0;
95
96        for &eid in &chain.edges {
97            let edge = &graph.edges[eid as usize];
98            let a = graph.node_pos(edge.start);
99            let b = graph.node_pos(edge.end);
100            let seg_len = (b - a).length();
101            match edge.road_type {
102                RoadType::Major => major_len += seg_len,
103                RoadType::Minor => minor_len += seg_len,
104            }
105        }
106
107        let winner = if major_len >= minor_len {
108            RoadType::Major
109        } else {
110            RoadType::Minor
111        };
112
113        // Overwrite all edges in this chain to the majority type.
114        for &eid in &chain.edges {
115            graph.edges[eid as usize].road_type = winner;
116        }
117    }
118}
119
120/// Rationalizes the road graph in-place.
121///
122/// **Phase 0 — Type unification:** Ensures each degree-2 chain between
123/// intersections has a single, consistent road type (majority-vote).
124///
125/// **Phase 1 — Arteries:** Traces continuous paths of same-type edges
126/// *through* intersections (by forward-vector alignment), then applies
127/// RDP + fillet globally. Side-streets severed by moved intersection nodes
128/// are reconnected by projecting onto the new artery geometry.
129///
130/// **Phase 2 — Residual chains:** Any edges not consumed by an artery are
131/// processed with the original chain-based RDP + fillet pass.
132pub fn rationalize_graph(graph: &mut RoadGraph, hm: &HeightMap, config: &RationalizeConfig) {
133    // --- Phase 0: Unify road types along degree-2 chains ---
134    unify_road_types(graph);
135
136    // --- Phase 0.5: Global Laplacian elevation smoothing ---
137    // Smooth elevations over the entire graph topology so that
138    // intersections themselves average their heights with neighbors,
139    // creating elevated bridges over dips and sunken cuts through bumps.
140    // This runs before chain/artery extraction so all geometry inherits
141    // the stabilized profile.
142    smooth_graph_elevations(graph, hm, config);
143
144    // --- Phase 1: Artery rationalization ---
145    // Process Major arteries first (avenues get priority), then Minor.
146    // Only arteries with 3+ nodes (2+ edges) benefit from global
147    // straightening. Shorter paths are left for Phase 2's chain pass.
148    // Multi-node arteries don't share edges (extract_arteries marks
149    // visited), so processing them from a single extraction is safe.
150    for &road_type in &[RoadType::Major, RoadType::Minor] {
151        let degrees = compute_active_degrees(graph);
152        let arteries = extract_arteries(graph, &degrees, road_type);
153
154        for artery in &arteries {
155            if artery.nodes.len() < 3 {
156                continue;
157            }
158            rationalize_artery(
159                graph,
160                hm,
161                artery.road_type,
162                &artery.nodes,
163                &artery.edges,
164                config,
165            );
166        }
167    }
168
169    // --- Phase 2: Residual chain rationalization ---
170    // Any edges not yet consumed by arteries get the original treatment.
171    let degrees = compute_active_degrees(graph);
172    let chains = extract_chains(graph, &degrees);
173
174    for chain in &chains {
175        rationalize_polyline(
176            graph,
177            hm,
178            chain.road_type,
179            &chain.nodes,
180            &chain.edges,
181            config,
182        );
183    }
184}
185
186/// Rationalizes a single artery: deactivates old edges, injects smoothed
187/// geometry, and reconnects severed side-streets.
188fn rationalize_artery(
189    graph: &mut RoadGraph,
190    hm: &HeightMap,
191    road_type: RoadType,
192    nodes: &[NodeId],
193    edges: &[EdgeId],
194    config: &RationalizeConfig,
195) {
196    let positions: Vec<Vec2> = nodes.iter().map(|&nid| graph.node_pos(nid)).collect();
197    if positions.len() < 2 {
198        return;
199    }
200
201    // 1. Decimate + Fillet
202    let simplified = ramer_douglas_peucker(&positions, config.rdp_tolerance);
203    if simplified.len() < 2 {
204        return;
205    }
206    let fillet_radius = match road_type {
207        RoadType::Major => config.major_fillet_radius,
208        RoadType::Minor => config.minor_fillet_radius,
209    };
210    let smoothed = fillet_corners(&simplified, fillet_radius, config.fillet_segments);
211    if smoothed.len() < 2 {
212        return;
213    }
214
215    // Compute smoothed elevations for the new polyline.
216    let elevations = smooth_elevations(&smoothed, hm, config);
217
218    // 2. Collect junction nodes along this artery that have side-street
219    //    connections (edges of a *different* type, or same-type edges not
220    //    part of this artery). These will need reconnection.
221    let artery_edge_set: Vec<bool> = {
222        let mut set = vec![false; graph.edges.len()];
223        for &eid in edges {
224            set[eid as usize] = true;
225        }
226        set
227    };
228
229    // For each interior artery node, find side-street edges that branch off.
230    // We record (node_id, edge_id) pairs for reconnection after rewrite.
231    let mut severed: Vec<(NodeId, EdgeId)> = Vec::new();
232    for &nid in nodes {
233        let node = &graph.nodes[nid as usize];
234        for &eid in &node.edges {
235            let e = &graph.edges[eid as usize];
236            if !e.active || artery_edge_set[eid as usize] {
237                continue;
238            }
239            // This is a side-street edge connected to an artery node.
240            severed.push((nid, eid));
241        }
242    }
243
244    // 3. Deactivate old artery edges.
245    for &eid in edges {
246        graph.edges[eid as usize].active = false;
247    }
248
249    // 4. Inject new smoothed geometry with elevation data.
250    // Invariant: positions.len() == nodes.len() and we returned early if
251    // positions.len() < 2, so nodes has at least 2 entries here.
252    let first_node = nodes[0];
253    let last_node = nodes[nodes.len() - 1];
254    let new_edge_ids = inject_polyline(
255        graph,
256        road_type,
257        first_node,
258        last_node,
259        &smoothed,
260        &elevations,
261    );
262
263    // 5. Reconnect severed side-streets.
264    // For each severed (old_artery_node, side_edge), find the closest point
265    // on the new artery polyline and rewire the side-street endpoint.
266    reconnect_side_streets(graph, &smoothed, &new_edge_ids, &severed);
267}
268
269/// Rationalizes a simple chain of degree-2 nodes (the original algorithm).
270fn rationalize_polyline(
271    graph: &mut RoadGraph,
272    hm: &HeightMap,
273    road_type: RoadType,
274    nodes: &[NodeId],
275    edges: &[EdgeId],
276    config: &RationalizeConfig,
277) {
278    let positions: Vec<Vec2> = nodes.iter().map(|&nid| graph.node_pos(nid)).collect();
279    if positions.len() < 2 {
280        return;
281    }
282
283    let simplified = ramer_douglas_peucker(&positions, config.rdp_tolerance);
284    if simplified.len() < 2 {
285        return;
286    }
287
288    let fillet_radius = match road_type {
289        RoadType::Major => config.major_fillet_radius,
290        RoadType::Minor => config.minor_fillet_radius,
291    };
292    let smoothed = fillet_corners(&simplified, fillet_radius, config.fillet_segments);
293    if smoothed.len() < 2 {
294        return;
295    }
296
297    let elevations = smooth_elevations(&smoothed, hm, config);
298
299    for &eid in edges {
300        graph.edges[eid as usize].active = false;
301    }
302
303    // Invariant as above: nodes.len() >= 2.
304    let first_node = nodes[0];
305    let last_node = nodes[nodes.len() - 1];
306    inject_polyline(
307        graph,
308        road_type,
309        first_node,
310        last_node,
311        &smoothed,
312        &elevations,
313    );
314}
315
316/// Injects a smoothed polyline into the graph, reusing `first_node` and
317/// `last_node` as endpoints. Elevations are assigned to each node.
318/// Returns the edge IDs of the new edges.
319fn inject_polyline(
320    graph: &mut RoadGraph,
321    road_type: RoadType,
322    first_node: NodeId,
323    last_node: NodeId,
324    smoothed: &[Vec2],
325    elevations: &[f32],
326) -> Vec<EdgeId> {
327    let mut new_edges = Vec::with_capacity(smoothed.len());
328    let mut prev_node = first_node;
329    for (i, &pos) in smoothed.iter().enumerate() {
330        let current_node = if i == 0 {
331            // Update first node's elevation to the smoothed value.
332            graph.nodes[first_node as usize].elevation = elevations[i];
333            first_node
334        } else if i == smoothed.len() - 1 {
335            graph.nodes[last_node as usize].elevation = elevations[i];
336            last_node
337        } else {
338            graph.add_node_with_elevation(pos, elevations[i])
339        };
340
341        if i > 0 {
342            let eid = graph.add_edge(prev_node, current_node, road_type);
343            new_edges.push(eid);
344        }
345        prev_node = current_node;
346    }
347    new_edges
348}
349
350// ---------------------------------------------------------------------------
351// Elevation smoothing
352// ---------------------------------------------------------------------------
353
354/// Runs Laplacian smoothing over the entire road graph's elevation profile.
355///
356/// Each active node's elevation is iteratively averaged with its active
357/// neighbors' elevations, weighted by inverse edge length so that closely
358/// spaced nodes don't dominate. This allows intersections themselves to
359/// float above dips and sink through bumps, producing a civil-engineered
360/// vertical alignment. Raw terrain heights are seeded once from the
361/// heightmap before smoothing begins.
362fn smooth_graph_elevations(graph: &mut RoadGraph, hm: &HeightMap, config: &RationalizeConfig) {
363    if config.elevation_smooth_passes == 0 {
364        return;
365    }
366
367    // Seed all node elevations from the heightmap.
368    for node in &mut graph.nodes {
369        node.elevation = hm.get_height_at(node.position.x, node.position.y);
370    }
371
372    // Build adjacency list of active neighbors for each node.
373    let n = graph.nodes.len();
374    let mut neighbors: Vec<Vec<(NodeId, f32)>> = vec![Vec::new(); n];
375    for edge in &graph.edges {
376        if !edge.active {
377            continue;
378        }
379        let a = edge.start as usize;
380        let b = edge.end as usize;
381        let dist = (graph.nodes[a].position - graph.nodes[b].position)
382            .length()
383            .max(1e-6);
384        let weight = 1.0 / dist;
385        neighbors[a].push((edge.end, weight));
386        neighbors[b].push((edge.start, weight));
387    }
388
389    // Laplacian smoothing passes. Track the maximum per-node delta and
390    // break early if the field has converged within `convergence_tolerance`.
391    let mut new_elevs = vec![0.0f32; n];
392    let tol = config.convergence_tolerance.max(0.0);
393    for _ in 0..config.elevation_smooth_passes {
394        let mut max_delta = 0.0f32;
395        for i in 0..n {
396            if neighbors[i].is_empty() {
397                new_elevs[i] = graph.nodes[i].elevation;
398                continue;
399            }
400            let mut sum = 0.0f32;
401            let mut total_weight = 0.0f32;
402            for &(neighbor, weight) in &neighbors[i] {
403                sum += graph.nodes[neighbor as usize].elevation * weight;
404                total_weight += weight;
405            }
406            // Blend: 50% self + 50% neighbor-weighted average.
407            let neighbor_avg = sum / total_weight;
408            new_elevs[i] = graph.nodes[i].elevation * 0.5 + neighbor_avg * 0.5;
409            let delta = (new_elevs[i] - graph.nodes[i].elevation).abs();
410            if delta > max_delta {
411                max_delta = delta;
412            }
413        }
414        for (node, &elev) in graph.nodes.iter_mut().zip(new_elevs.iter()) {
415            node.elevation = elev;
416        }
417        if tol > 0.0 && max_delta < tol {
418            break;
419        }
420    }
421
422    // Max grade clamping over edges (forward + backward BFS-order passes).
423    if config.max_grade > 0.0 {
424        for _ in 0..3 {
425            let mut max_delta = 0.0f32;
426            for edge in &graph.edges {
427                if !edge.active {
428                    continue;
429                }
430                let a = edge.start as usize;
431                let b = edge.end as usize;
432                let dist = (graph.nodes[a].position - graph.nodes[b].position).length();
433                let max_rise = dist * config.max_grade;
434                // Clamp b relative to a.
435                if graph.nodes[b].elevation > graph.nodes[a].elevation + max_rise {
436                    let new_b = graph.nodes[a].elevation + max_rise;
437                    max_delta = max_delta.max((graph.nodes[b].elevation - new_b).abs());
438                    graph.nodes[b].elevation = new_b;
439                } else if graph.nodes[b].elevation < graph.nodes[a].elevation - max_rise {
440                    let new_b = graph.nodes[a].elevation - max_rise;
441                    max_delta = max_delta.max((graph.nodes[b].elevation - new_b).abs());
442                    graph.nodes[b].elevation = new_b;
443                }
444                // Clamp a relative to b.
445                if graph.nodes[a].elevation > graph.nodes[b].elevation + max_rise {
446                    let new_a = graph.nodes[b].elevation + max_rise;
447                    max_delta = max_delta.max((graph.nodes[a].elevation - new_a).abs());
448                    graph.nodes[a].elevation = new_a;
449                } else if graph.nodes[a].elevation < graph.nodes[b].elevation - max_rise {
450                    let new_a = graph.nodes[b].elevation - max_rise;
451                    max_delta = max_delta.max((graph.nodes[a].elevation - new_a).abs());
452                    graph.nodes[a].elevation = new_a;
453                }
454            }
455            if tol > 0.0 && max_delta < tol {
456                break;
457            }
458        }
459    }
460}
461
462/// Projects smoothed elevations from existing graph nodes onto new polyline
463/// points (e.g. fillet geometry), then applies max-grade clamping.
464///
465/// For points that coincide with existing graph nodes, the pre-smoothed
466/// elevation is used directly. For interpolated fillet points, the elevation
467/// is linearly interpolated along the polyline from the nearest node
468/// elevations, inheriting the globally smoothed profile.
469fn smooth_elevations(points: &[Vec2], hm: &HeightMap, config: &RationalizeConfig) -> Vec<f32> {
470    let n = points.len();
471    // Use the globally-smoothed heightmap sample as a baseline — the global
472    // Laplacian pass has already written stabilized elevations into graph
473    // nodes, and new fillet points sample the terrain which is close enough.
474    let mut elevs: Vec<f32> = points.iter().map(|p| hm.get_height_at(p.x, p.y)).collect();
475
476    // Light local smoothing to blend fillet points with their neighbors.
477    let tol = config.convergence_tolerance.max(0.0);
478    for _ in 0..3_u32.min(config.elevation_smooth_passes) {
479        let prev = elevs.clone();
480        let mut max_delta = 0.0f32;
481        for i in 1..n - 1 {
482            let new_v = (prev[i - 1] + prev[i] + prev[i + 1]) / 3.0;
483            max_delta = max_delta.max((new_v - elevs[i]).abs());
484            elevs[i] = new_v;
485        }
486        if tol > 0.0 && max_delta < tol {
487            break;
488        }
489    }
490
491    // Max grade clamping: walk forward then backward, clamping slope.
492    if config.max_grade > 0.0 {
493        // Forward pass.
494        for i in 1..n {
495            let dist = (points[i] - points[i - 1]).length();
496            let max_rise = dist * config.max_grade;
497            if elevs[i] > elevs[i - 1] + max_rise {
498                elevs[i] = elevs[i - 1] + max_rise;
499            } else if elevs[i] < elevs[i - 1] - max_rise {
500                elevs[i] = elevs[i - 1] - max_rise;
501            }
502        }
503        // Backward pass.
504        for i in (0..n - 1).rev() {
505            let dist = (points[i + 1] - points[i]).length();
506            let max_rise = dist * config.max_grade;
507            if elevs[i] > elevs[i + 1] + max_rise {
508                elevs[i] = elevs[i + 1] + max_rise;
509            } else if elevs[i] < elevs[i + 1] - max_rise {
510                elevs[i] = elevs[i + 1] - max_rise;
511            }
512        }
513    }
514
515    elevs
516}
517
518/// Reconnects side-street edges that were severed when artery nodes moved.
519///
520/// For each severed `(old_artery_node, side_edge)`:
521/// 1. Find the closest *active* edge to the old node's position.
522/// 2. Split that edge at the projection point.
523/// 3. Rewire the side-street edge to connect to the new split node.
524///
525/// We search for the nearest active edge each time (rather than using stale
526/// edge IDs) because previous splits invalidate the original edge list.
527fn reconnect_side_streets(
528    graph: &mut RoadGraph,
529    _new_polyline: &[Vec2],
530    new_edge_ids: &[EdgeId],
531    severed: &[(NodeId, EdgeId)],
532) {
533    if severed.is_empty() || new_edge_ids.is_empty() {
534        return;
535    }
536
537    // Track which edges belong to the new artery (including children from
538    // splits). We seed with the initial new edges and grow as splits occur.
539    let mut artery_edges: Vec<bool> = vec![false; graph.edges.len()];
540    for &eid in new_edge_ids {
541        artery_edges[eid as usize] = true;
542    }
543
544    for &(old_node, side_eid) in severed {
545        if !graph.edges[side_eid as usize].active {
546            continue;
547        }
548
549        let old_pos = graph.node_pos(old_node);
550
551        // Find the closest active artery edge to old_pos.
552        let mut best_eid: Option<EdgeId> = None;
553        let mut best_dist_sq = f32::MAX;
554        let mut best_proj = old_pos;
555
556        // Grow the tracking vec if new edges were added by prior splits.
557        artery_edges.resize(graph.edges.len(), false);
558
559        for (eid, edge) in graph.edges.iter().enumerate() {
560            if !edge.active || !artery_edges[eid] {
561                continue;
562            }
563            let a = graph.node_pos(edge.start);
564            let b = graph.node_pos(edge.end);
565            let proj = closest_point_on_segment(old_pos, a, b);
566            let dist_sq = (proj - old_pos).length_squared();
567            if dist_sq < best_dist_sq {
568                best_dist_sq = dist_sq;
569                best_eid = Some(eid as EdgeId);
570                best_proj = proj;
571            }
572        }
573
574        let Some(target_eid) = best_eid else { continue };
575
576        // Check if the projection is very close to an existing endpoint —
577        // if so, rewire directly to that node instead of splitting.
578        let target_edge = &graph.edges[target_eid as usize];
579        let start_pos = graph.node_pos(target_edge.start);
580        let end_pos = graph.node_pos(target_edge.end);
581        let snap_threshold_sq = 1e-4;
582
583        let connect_node = if (best_proj - start_pos).length_squared() < snap_threshold_sq {
584            target_edge.start
585        } else if (best_proj - end_pos).length_squared() < snap_threshold_sq {
586            target_edge.end
587        } else {
588            // Split the artery edge at the projection point.
589            let (split_node, ea, eb) = graph.split_edge(target_eid, best_proj);
590            // Track the child edges as artery edges.
591            artery_edges.resize(graph.edges.len(), false);
592            artery_edges[ea as usize] = true;
593            artery_edges[eb as usize] = true;
594            split_node
595        };
596
597        rewire_edge_endpoint(graph, side_eid, old_node, connect_node);
598    }
599}
600
601/// Rewires one endpoint of an edge from `old_node` to `new_node`.
602fn rewire_edge_endpoint(
603    graph: &mut RoadGraph,
604    edge_id: EdgeId,
605    old_node: NodeId,
606    new_node: NodeId,
607) {
608    // Update the edge's endpoint.
609    let edge = &mut graph.edges[edge_id as usize];
610    if edge.start == old_node {
611        edge.start = new_node;
612    } else if edge.end == old_node {
613        edge.end = new_node;
614    } else {
615        return; // old_node wasn't an endpoint — shouldn't happen.
616    }
617
618    // Update adjacency lists.
619    graph.nodes[old_node as usize]
620        .edges
621        .retain(|&e| e != edge_id);
622    graph.nodes[new_node as usize].edges.push(edge_id);
623}
624
625// ---------------------------------------------------------------------------
626// Ramer-Douglas-Peucker
627// ---------------------------------------------------------------------------
628
629/// Simplifies a polyline by recursively removing points closer than
630/// `tolerance` to the line between endpoints.
631pub fn ramer_douglas_peucker(points: &[Vec2], tolerance: f32) -> Vec<Vec2> {
632    if points.len() <= 2 {
633        return points.to_vec();
634    }
635
636    let mut keep = vec![false; points.len()];
637    keep[0] = true;
638    keep[points.len() - 1] = true;
639
640    rdp_recurse(
641        points,
642        0,
643        points.len() - 1,
644        tolerance * tolerance,
645        &mut keep,
646    );
647
648    points
649        .iter()
650        .zip(keep.iter())
651        .filter(|(_, k)| **k)
652        .map(|(p, _)| *p)
653        .collect()
654}
655
656fn rdp_recurse(points: &[Vec2], start: usize, end: usize, tol_sq: f32, keep: &mut [bool]) {
657    if end <= start + 1 {
658        return;
659    }
660
661    let a = points[start];
662    let b = points[end];
663    let ab = b - a;
664    let ab_len_sq = ab.length_squared();
665
666    let mut max_dist_sq = 0.0f32;
667    let mut max_idx = start;
668
669    for (i, pt) in points
670        .iter()
671        .enumerate()
672        .skip(start + 1)
673        .take(end - start - 1)
674    {
675        let dist_sq = if ab_len_sq < 1e-12 {
676            (*pt - a).length_squared()
677        } else {
678            let t = ((*pt - a).dot(ab) / ab_len_sq).clamp(0.0, 1.0);
679            (*pt - (a + ab * t)).length_squared()
680        };
681
682        if dist_sq > max_dist_sq {
683            max_dist_sq = dist_sq;
684            max_idx = i;
685        }
686    }
687
688    if max_dist_sq > tol_sq {
689        keep[max_idx] = true;
690        rdp_recurse(points, start, max_idx, tol_sq, keep);
691        rdp_recurse(points, max_idx, end, tol_sq, keep);
692    }
693}
694
695// ---------------------------------------------------------------------------
696// Fillet corners
697// ---------------------------------------------------------------------------
698
699/// Replaces sharp corners in a polyline with smooth quadratic Bézier arcs.
700///
701/// For each interior vertex B with neighbours A and C, the fillet arc is
702/// tangent to segments AB and BC at a distance of `radius · tan(half_angle)`
703/// from B, clamped so adjacent fillets don't overlap.
704pub fn fillet_corners(points: &[Vec2], radius: f32, segments: u32) -> Vec<Vec2> {
705    if points.len() <= 2 || radius <= 0.0 || segments == 0 {
706        return points.to_vec();
707    }
708
709    let n = points.len();
710    let segments = segments.max(1);
711
712    // Pre-compute segment lengths for setback clamping.
713    let seg_lengths: Vec<f32> = points.windows(2).map(|w| (w[1] - w[0]).length()).collect();
714
715    let mut result = Vec::with_capacity(n + (n - 2) * segments as usize);
716    result.push(points[0]);
717
718    for i in 1..n - 1 {
719        let a = points[i - 1];
720        let b = points[i];
721        let c = points[i + 1];
722
723        let ba = a - b;
724        let bc = c - b;
725        let ba_len = seg_lengths[i - 1];
726        let bc_len = seg_lengths[i];
727
728        if ba_len < 1e-6 || bc_len < 1e-6 {
729            result.push(b);
730            continue;
731        }
732
733        let ba_dir = ba / ba_len;
734        let bc_dir = bc / bc_len;
735
736        // Half-angle between the two legs.
737        let cos_theta = ba_dir.dot(bc_dir).clamp(-1.0, 1.0);
738
739        // Nearly straight — no fillet needed.
740        if cos_theta > 0.999 {
741            result.push(b);
742            continue;
743        }
744
745        // Nearly a U-turn — skip filleting to avoid numerical issues.
746        if cos_theta < -0.999 {
747            result.push(b);
748            continue;
749        }
750
751        let half_angle = cos_theta.acos() * 0.5;
752        let tan_half = half_angle.tan();
753        if tan_half.abs() < 1e-6 {
754            result.push(b);
755            continue;
756        }
757
758        // Desired setback along each leg.
759        let mut setback = radius / tan_half;
760
761        // Clamp so adjacent fillets don't overlap: each fillet can use at most
762        // half of each incident segment.
763        setback = setback.min(ba_len * 0.5).min(bc_len * 0.5);
764
765        // Quadratic Bézier: P0 (on AB), control = B, P2 (on BC).
766        let p0 = b + ba_dir * setback;
767        let p2 = b + bc_dir * setback;
768
769        for j in 0..=segments {
770            let t = j as f32 / segments as f32;
771            let q = quadratic_bezier(p0, b, p2, t);
772            result.push(q);
773        }
774    }
775
776    result.push(points[n - 1]);
777    result
778}
779
780/// Evaluates a quadratic Bézier curve at parameter `t`.
781fn quadratic_bezier(p0: Vec2, p1: Vec2, p2: Vec2, t: f32) -> Vec2 {
782    let inv = 1.0 - t;
783    p0 * (inv * inv) + p1 * (2.0 * inv * t) + p2 * (t * t)
784}
785
786// ---------------------------------------------------------------------------
787// Tests
788// ---------------------------------------------------------------------------
789
790#[cfg(test)]
791mod tests {
792    use super::*;
793
794    #[test]
795    fn rdp_preserves_endpoints() {
796        let pts = vec![
797            Vec2::new(0.0, 0.0),
798            Vec2::new(1.0, 0.01),
799            Vec2::new(2.0, 0.0),
800        ];
801        let result = ramer_douglas_peucker(&pts, 0.1);
802        assert_eq!(result.len(), 2); // middle point within tolerance
803        assert_eq!(result[0], pts[0]);
804        assert_eq!(result[1], pts[2]);
805    }
806
807    #[test]
808    fn rdp_keeps_significant_points() {
809        let pts = vec![
810            Vec2::new(0.0, 0.0),
811            Vec2::new(5.0, 10.0),
812            Vec2::new(10.0, 0.0),
813        ];
814        let result = ramer_douglas_peucker(&pts, 1.0);
815        assert_eq!(result.len(), 3); // deviation > tolerance
816    }
817
818    #[test]
819    fn rdp_short_input() {
820        let pts = vec![Vec2::new(0.0, 0.0), Vec2::new(1.0, 1.0)];
821        let result = ramer_douglas_peucker(&pts, 1.0);
822        assert_eq!(result.len(), 2);
823    }
824
825    #[test]
826    fn fillet_straight_line_unchanged() {
827        let pts = vec![
828            Vec2::new(0.0, 0.0),
829            Vec2::new(5.0, 0.0),
830            Vec2::new(10.0, 0.0),
831        ];
832        let result = fillet_corners(&pts, 2.0, 4);
833        // Nearly straight → just passes through, no arc expansion
834        // First, middle (kept as-is), last
835        assert_eq!(result.len(), 3);
836    }
837
838    #[test]
839    fn fillet_right_angle_adds_points() {
840        let pts = vec![
841            Vec2::new(0.0, 0.0),
842            Vec2::new(10.0, 0.0),
843            Vec2::new(10.0, 10.0),
844        ];
845        let result = fillet_corners(&pts, 3.0, 4);
846        // Should have: first point + 5 bezier samples (0..=4) + last point = 7
847        assert_eq!(result.len(), 7);
848        // First and last should be preserved.
849        assert_eq!(result[0], pts[0]);
850        assert_eq!(*result.last().unwrap(), pts[2]);
851    }
852
853    #[test]
854    fn fillet_clamps_setback_on_short_segments() {
855        // Very short middle segment — setback should be clamped.
856        let pts = vec![
857            Vec2::new(0.0, 0.0),
858            Vec2::new(1.0, 0.0),
859            Vec2::new(1.0, 1.0),
860        ];
861        let result = fillet_corners(&pts, 100.0, 4);
862        // Should still produce valid output despite huge radius.
863        assert!(result.len() >= 3);
864        assert_eq!(result[0], pts[0]);
865        assert_eq!(*result.last().unwrap(), pts[2]);
866    }
867
868    #[test]
869    fn fillet_two_points_passthrough() {
870        let pts = vec![Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0)];
871        let result = fillet_corners(&pts, 3.0, 4);
872        assert_eq!(result.len(), 2);
873    }
874
875    #[test]
876    fn early_termination_preserves_output_on_flat_input() {
877        use crate::graph::RoadGraph;
878
879        // On a flat heightmap, all node elevations start equal → the
880        // Laplacian pass produces zero deltas in iteration 1 and early
881        // termination kicks in. With or without early term, the resulting
882        // elevations must be identical.
883        let mut g = RoadGraph::default();
884        let n0 = g.add_node(Vec2::new(0.0, 0.0));
885        let n1 = g.add_node(Vec2::new(10.0, 0.0));
886        let n2 = g.add_node(Vec2::new(20.0, 0.0));
887        g.add_edge(n0, n1, RoadType::Major);
888        g.add_edge(n1, n2, RoadType::Major);
889
890        let hm = HeightMap::new(32, 32, 1.0);
891
892        let mut g_early = g.clone();
893        let mut g_full = g.clone();
894
895        rationalize_graph(
896            &mut g_early,
897            &hm,
898            &RationalizeConfig {
899                convergence_tolerance: 1e-2,
900                ..Default::default()
901            },
902        );
903        rationalize_graph(
904            &mut g_full,
905            &hm,
906            &RationalizeConfig {
907                convergence_tolerance: 0.0,
908                ..Default::default()
909            },
910        );
911
912        for (a, b) in g_early.nodes.iter().zip(g_full.nodes.iter()) {
913            assert!(
914                (a.elevation - b.elevation).abs() < 1e-5,
915                "early-term and full passes must agree on flat input"
916            );
917        }
918    }
919
920    #[test]
921    fn rationalize_simple_chain() {
922        use crate::graph::RoadGraph;
923
924        // Build a zigzag chain: A -- B -- C -- D
925        // Where B is slightly off the straight line A→D.
926        let mut g = RoadGraph::default();
927        let _a = g.add_node(Vec2::new(0.0, 0.0)); // 0
928        let _b = g.add_node(Vec2::new(10.0, 0.5)); // 1 - slight deviation
929        let _c = g.add_node(Vec2::new(20.0, 0.0)); // 2
930        let _d = g.add_node(Vec2::new(30.0, 0.0)); // 3
931
932        // Make A and D true junctions (degree >= 3) with two stubs each.
933        let stub_a1 = g.add_node(Vec2::new(-10.0, 0.0)); // 4
934        let stub_a2 = g.add_node(Vec2::new(0.0, -10.0)); // 5
935        let stub_d1 = g.add_node(Vec2::new(40.0, 0.0)); // 6
936        let stub_d2 = g.add_node(Vec2::new(30.0, -10.0)); // 7
937        g.add_edge(stub_a1, _a, RoadType::Major);
938        g.add_edge(stub_a2, _a, RoadType::Major);
939        g.add_edge(_a, _b, RoadType::Major);
940        g.add_edge(_b, _c, RoadType::Major);
941        g.add_edge(_c, _d, RoadType::Major);
942        g.add_edge(_d, stub_d1, RoadType::Major);
943        g.add_edge(_d, stub_d2, RoadType::Major);
944
945        let config = RationalizeConfig {
946            enabled: true,
947            rdp_tolerance: 2.0,
948            major_fillet_radius: 0.0,
949            minor_fillet_radius: 0.0,
950            fillet_segments: 4,
951            elevation_smooth_passes: 0,
952            max_grade: 0.0,
953            convergence_tolerance: 0.0,
954        };
955
956        let hm = symbios_ground::HeightMap::new(64, 64, 2.0);
957        rationalize_graph(&mut g, &hm, &config);
958
959        // Verify structural properties after rationalization:
960        // 1. The graph should have a connected active sub-graph.
961        let active_count = g.edges.iter().filter(|e| e.active).count();
962        assert!(
963            active_count >= 3,
964            "expected at least 3 active edges (avenue + 2 stubs), got {active_count}"
965        );
966
967        // 2. Leaf nodes (stub_a1, stub_a2, stub_d1, stub_d2) should each
968        //    have at least one active edge connecting them to the network.
969        for &leaf in &[stub_a1, stub_a2, stub_d1, stub_d2] {
970            let has_active = g.nodes[leaf as usize]
971                .edges
972                .iter()
973                .any(|&eid| g.edges[eid as usize].active);
974            assert!(has_active, "leaf node {leaf} should have active edges");
975        }
976
977        // 3. There should be a continuous active path from stub_a1's
978        //    neighborhood to stub_d1's neighborhood (the straightened avenue).
979        let active_major: Vec<_> = g
980            .edges
981            .iter()
982            .filter(|e| e.active && e.road_type == RoadType::Major)
983            .collect();
984        assert!(!active_major.is_empty(), "should have active Major edges");
985    }
986
987    #[test]
988    fn artery_rationalizes_through_intersections() {
989        use crate::graph::RoadGraph;
990
991        // Build a long Major avenue: A -- B -- C -- D -- E
992        // with Minor side-streets branching off B and D.
993        // B and D are T-junctions (degree 3).
994        let mut g = RoadGraph::default();
995        let _a = g.add_node(Vec2::new(0.0, 0.0));
996        let _b = g.add_node(Vec2::new(10.0, 0.3)); // slight wobble
997        let _c = g.add_node(Vec2::new(20.0, 0.0));
998        let _d = g.add_node(Vec2::new(30.0, 0.2)); // slight wobble
999        let _e = g.add_node(Vec2::new(40.0, 0.0));
1000
1001        // Minor side-streets
1002        let s1 = g.add_node(Vec2::new(10.0, -15.0));
1003        let s2 = g.add_node(Vec2::new(30.0, -15.0));
1004
1005        // Major avenue edges
1006        g.add_edge(_a, _b, RoadType::Major); // 0
1007        g.add_edge(_b, _c, RoadType::Major); // 1
1008        g.add_edge(_c, _d, RoadType::Major); // 2
1009        g.add_edge(_d, _e, RoadType::Major); // 3
1010
1011        // Minor side-streets
1012        g.add_edge(_b, s1, RoadType::Minor); // 4
1013        g.add_edge(_d, s2, RoadType::Minor); // 5
1014
1015        let config = RationalizeConfig {
1016            enabled: true,
1017            rdp_tolerance: 1.0,
1018            major_fillet_radius: 0.0,
1019            minor_fillet_radius: 0.0,
1020            fillet_segments: 4,
1021            elevation_smooth_passes: 0,
1022            max_grade: 0.0,
1023            convergence_tolerance: 0.0,
1024        };
1025
1026        let hm = symbios_ground::HeightMap::new(64, 64, 2.0);
1027        rationalize_graph(&mut g, &hm, &config);
1028
1029        // The original Major avenue edges should be deactivated.
1030        assert!(!g.edges[0].active, "Major A→B should be deactivated");
1031        assert!(!g.edges[1].active, "Major B→C should be deactivated");
1032        assert!(!g.edges[2].active, "Major C→D should be deactivated");
1033        assert!(!g.edges[3].active, "Major D→E should be deactivated");
1034
1035        // New Major edges should exist forming the straightened avenue.
1036        let new_major: Vec<_> = g
1037            .edges
1038            .iter()
1039            .filter(|e| e.active && e.road_type == RoadType::Major)
1040            .collect();
1041        assert!(!new_major.is_empty(), "should have new Major artery edges");
1042
1043        // Minor side-streets should still connect s1 and s2 to the network.
1044        // They may have been rewired (original edge deactivated, replaced by
1045        // Phase 2) but equivalent active Minor edges must exist.
1046        let active_minor: Vec<_> = g
1047            .edges
1048            .iter()
1049            .filter(|e| e.active && e.road_type == RoadType::Minor)
1050            .collect();
1051        assert_eq!(
1052            active_minor.len(),
1053            2,
1054            "should have 2 active Minor side-streets"
1055        );
1056
1057        // s1 and s2 must each have an active edge.
1058        for &leaf in &[s1, s2] {
1059            let has_active = g.nodes[leaf as usize]
1060                .edges
1061                .iter()
1062                .any(|&eid| g.edges[eid as usize].active);
1063            assert!(
1064                has_active,
1065                "side-street leaf node {leaf} should be connected"
1066            );
1067        }
1068    }
1069}