1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct RationalizeConfig {
32 pub enabled: bool,
34 pub rdp_tolerance: f32,
36 pub major_fillet_radius: f32,
38 pub minor_fillet_radius: f32,
40 pub fillet_segments: u32,
42 pub elevation_smooth_passes: u32,
47 pub max_grade: f32,
50 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
75pub fn unify_road_types(graph: &mut RoadGraph) {
88 let degrees = compute_active_degrees(graph);
89 let chains = extract_chains_any_type(graph, °rees);
90
91 for chain in &chains {
92 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 for &eid in &chain.edges {
115 graph.edges[eid as usize].road_type = winner;
116 }
117 }
118}
119
120pub fn rationalize_graph(graph: &mut RoadGraph, hm: &HeightMap, config: &RationalizeConfig) {
133 unify_road_types(graph);
135
136 smooth_graph_elevations(graph, hm, config);
143
144 for &road_type in &[RoadType::Major, RoadType::Minor] {
151 let degrees = compute_active_degrees(graph);
152 let arteries = extract_arteries(graph, °rees, 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 let degrees = compute_active_degrees(graph);
172 let chains = extract_chains(graph, °rees);
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
186fn 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 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 let elevations = smooth_elevations(&smoothed, hm, config);
217
218 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 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 severed.push((nid, eid));
241 }
242 }
243
244 for &eid in edges {
246 graph.edges[eid as usize].active = false;
247 }
248
249 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 reconnect_side_streets(graph, &smoothed, &new_edge_ids, &severed);
267}
268
269fn 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 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
316fn 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 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
350fn smooth_graph_elevations(graph: &mut RoadGraph, hm: &HeightMap, config: &RationalizeConfig) {
363 if config.elevation_smooth_passes == 0 {
364 return;
365 }
366
367 for node in &mut graph.nodes {
369 node.elevation = hm.get_height_at(node.position.x, node.position.y);
370 }
371
372 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 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 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 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 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 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
462fn smooth_elevations(points: &[Vec2], hm: &HeightMap, config: &RationalizeConfig) -> Vec<f32> {
470 let n = points.len();
471 let mut elevs: Vec<f32> = points.iter().map(|p| hm.get_height_at(p.x, p.y)).collect();
475
476 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 if config.max_grade > 0.0 {
493 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 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
518fn 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 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 let mut best_eid: Option<EdgeId> = None;
553 let mut best_dist_sq = f32::MAX;
554 let mut best_proj = old_pos;
555
556 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 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 let (split_node, ea, eb) = graph.split_edge(target_eid, best_proj);
590 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
601fn rewire_edge_endpoint(
603 graph: &mut RoadGraph,
604 edge_id: EdgeId,
605 old_node: NodeId,
606 new_node: NodeId,
607) {
608 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; }
617
618 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
625pub 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
695pub 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 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 let cos_theta = ba_dir.dot(bc_dir).clamp(-1.0, 1.0);
738
739 if cos_theta > 0.999 {
741 result.push(b);
742 continue;
743 }
744
745 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 let mut setback = radius / tan_half;
760
761 setback = setback.min(ba_len * 0.5).min(bc_len * 0.5);
764
765 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
780fn 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#[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); 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); }
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 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 assert_eq!(result.len(), 7);
848 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 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 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 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 let mut g = RoadGraph::default();
927 let _a = g.add_node(Vec2::new(0.0, 0.0)); let _b = g.add_node(Vec2::new(10.0, 0.5)); let _c = g.add_node(Vec2::new(20.0, 0.0)); let _d = g.add_node(Vec2::new(30.0, 0.0)); let stub_a1 = g.add_node(Vec2::new(-10.0, 0.0)); let stub_a2 = g.add_node(Vec2::new(0.0, -10.0)); let stub_d1 = g.add_node(Vec2::new(40.0, 0.0)); let stub_d2 = g.add_node(Vec2::new(30.0, -10.0)); 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 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 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 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 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)); let _c = g.add_node(Vec2::new(20.0, 0.0));
998 let _d = g.add_node(Vec2::new(30.0, 0.2)); let _e = g.add_node(Vec2::new(40.0, 0.0));
1000
1001 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 g.add_edge(_a, _b, RoadType::Major); g.add_edge(_b, _c, RoadType::Major); g.add_edge(_c, _d, RoadType::Major); g.add_edge(_d, _e, RoadType::Major); g.add_edge(_b, s1, RoadType::Minor); g.add_edge(_d, s2, RoadType::Minor); 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 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 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 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 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}