Skip to main content

symbios_tensor/
roads_3d.rs

1//! Engine-agnostic 3D road mesh generation.
2//!
3//! Takes a [`RoadGraph`] and [`HeightMap`] and produces [`ProceduralMesh`]
4//! vertex buffers for hub (intersection) and ribbon (street) geometry.
5//!
6//! **Hubs (degree 3+)** are procedural polygons derived from the 2D
7//! intersection of incoming road boundaries — each ribbon is truncated
8//! exactly where adjacent road boundaries meet, and the hub fills the
9//! remaining polygon with a triangle fan. Skirts are generated only in
10//! the angular gaps between roads.
11//!
12//! **Dead-end caps (degree 1)** retain the legacy rounded N-gon style.
13//!
14//! **Ribbons** are extruded strips that follow chains of degree-2 nodes,
15//! truncated at hub / cap boundaries using the truncation map.
16
17use std::collections::HashMap;
18
19use glam::{Vec2, Vec3};
20use symbios_ground::HeightMap;
21
22use crate::graph::{EdgeId, NodeId, RoadGraph, RoadType};
23use crate::topology;
24
25/// Engine-agnostic mesh container.
26///
27/// Vertices use a Y-up coordinate system: `[x, y, z]` where Y is the
28/// world-space height sampled from the [`HeightMap`].
29#[derive(Debug, Clone, Default)]
30pub struct ProceduralMesh {
31    /// Vertex positions as `[x, y, z]` (Y-up).
32    pub vertices: Vec<[f32; 3]>,
33    /// Per-vertex normals (currently always `[0, 1, 0]` — flat upward).
34    pub normals: Vec<[f32; 3]>,
35    /// Per-vertex texture coordinates `[u, v]`.
36    pub uvs: Vec<[f32; 2]>,
37    /// Triangle indices into the vertex/normal/uv arrays.
38    pub indices: Vec<u32>,
39}
40
41impl ProceduralMesh {
42    /// Merge another mesh into this one, offsetting indices.
43    pub fn append(&mut self, other: &ProceduralMesh) {
44        let base = self.vertices.len() as u32;
45        self.vertices.extend_from_slice(&other.vertices);
46        self.normals.extend_from_slice(&other.normals);
47        self.uvs.extend_from_slice(&other.uvs);
48        self.indices.extend(other.indices.iter().map(|i| i + base));
49    }
50}
51
52/// Configuration for 3D road mesh generation.
53#[derive(Debug, Clone)]
54pub struct RoadMeshConfig {
55    /// Half-width of major roads (world units).
56    pub major_half_width: f32,
57    /// Half-width of minor roads (world units).
58    pub minor_half_width: f32,
59    /// Number of sides for dead-end cap polygons (e.g. 8 = octagon).
60    pub hub_sides: u32,
61    /// Depth bias: vertices are raised above the terrain by this amount to
62    /// prevent z-fighting.
63    pub depth_bias: f32,
64    /// UV texture scale: world units per texture repeat.
65    pub texture_scale: f32,
66    /// Legacy: subdivisions per graph edge for Catmull-Rom spline ribbons.
67    /// Ignored when the graph has been rationalized (geometry is already smooth).
68    pub spline_subdivisions: u32,
69    /// Extra radius added to dead-end caps beyond the road half-width,
70    /// creating a wider turning zone (world units).
71    pub curb_radius: f32,
72    /// Embankment skirt configuration.
73    pub skirt: SkirtConfig,
74}
75
76impl Default for RoadMeshConfig {
77    fn default() -> Self {
78        Self {
79            major_half_width: 3.0,
80            minor_half_width: 2.0,
81            hub_sides: 8,
82            depth_bias: 0.05,
83            texture_scale: 0.1,
84            spline_subdivisions: 8,
85            curb_radius: 2.0,
86            skirt: SkirtConfig::default(),
87        }
88    }
89}
90
91/// Generated road meshes split by component type.
92#[derive(Debug, Clone, Default)]
93pub struct RoadMeshes {
94    /// Intersection / dead-end hub polygons.
95    pub hubs: ProceduralMesh,
96    /// Street ribbon strips (flat asphalt surface).
97    pub ribbons: ProceduralMesh,
98    /// Embankment skirts that taper from the road edge down to terrain.
99    pub skirts: ProceduralMesh,
100}
101
102/// Configuration for the embankment skirts flanking roads.
103#[derive(Debug, Clone)]
104pub struct SkirtConfig {
105    /// Width of the skirt extending outward from the road edge (world units).
106    pub width: f32,
107    /// How far below the terrain surface the skirt buries itself.
108    pub bury_depth: f32,
109}
110
111impl Default for SkirtConfig {
112    fn default() -> Self {
113        Self {
114            width: 3.0,
115            bury_depth: 0.5,
116        }
117    }
118}
119
120// ---------------------------------------------------------------------------
121// Public API
122// ---------------------------------------------------------------------------
123
124/// Generates 3D road meshes from a road graph and heightmap.
125pub fn generate_road_meshes(
126    graph: &RoadGraph,
127    heightmap: &HeightMap,
128    config: &RoadMeshConfig,
129) -> RoadMeshes {
130    let mut meshes = RoadMeshes::default();
131
132    let degrees = compute_active_degrees(graph);
133    let truncations = compute_truncations(graph, &degrees, config);
134
135    // --- Hubs (degree != 2) ---
136    for (node_id, &deg) in degrees.iter().enumerate() {
137        if deg == 0 || deg == 2 {
138            continue;
139        }
140        let nid = node_id as NodeId;
141        if deg == 1 {
142            let (hub, hub_skirt) = generate_hub_cap(graph, nid, heightmap, config);
143            meshes.hubs.append(&hub);
144            meshes.skirts.append(&hub_skirt);
145        } else {
146            let (hub, hub_skirt) =
147                generate_hub_procedural(graph, nid, &truncations, heightmap, config);
148            meshes.hubs.append(&hub);
149            meshes.skirts.append(&hub_skirt);
150        }
151    }
152
153    // --- Ribbons (chains of degree-2 nodes) ---
154    let chains = extract_chains(graph, &degrees);
155    for chain in &chains {
156        let (ribbon, skirt) = generate_ribbon(graph, chain, &truncations, heightmap, config);
157        meshes.ribbons.append(&ribbon);
158        meshes.skirts.append(&skirt);
159    }
160
161    meshes
162}
163
164// ---------------------------------------------------------------------------
165// Node degree computation (delegated to topology module)
166// ---------------------------------------------------------------------------
167
168fn compute_active_degrees(graph: &RoadGraph) -> Vec<u32> {
169    topology::compute_active_degrees(graph)
170}
171
172// ---------------------------------------------------------------------------
173// Truncation computation
174// ---------------------------------------------------------------------------
175
176/// Computes per-edge truncation distances at every non-degree-2 node.
177///
178/// For dead ends (degree 1), truncation = half_width + curb_radius (cap radius).
179///
180/// For intersections (degree 3+), incoming edges are sorted radially and
181/// adjacent boundary lines are intersected to find the exact distance along
182/// each edge's centerline where the ribbon must be cut. The boundary width
183/// is `half_width + skirt_width` so that neither asphalt nor skirt overlaps.
184fn compute_truncations(
185    graph: &RoadGraph,
186    degrees: &[u32],
187    config: &RoadMeshConfig,
188) -> HashMap<(NodeId, EdgeId), f32> {
189    let mut truncations = HashMap::new();
190    let skirt_w = config.skirt.width;
191
192    for (node_idx, &deg) in degrees.iter().enumerate() {
193        if deg == 0 || deg == 2 {
194            continue;
195        }
196
197        let nid = node_idx as NodeId;
198        let center = graph.node_pos(nid);
199        let node = &graph.nodes[node_idx];
200
201        // Collect active edges with their geometry.
202        let mut arms: Vec<(EdgeId, Vec2, Vec2, f32)> = Vec::new(); // (eid, dir, right, half_width)
203        for &eid in &node.edges {
204            let edge = &graph.edges[eid as usize];
205            if !edge.active {
206                continue;
207            }
208            let neighbor = graph.opposite(eid, nid);
209            let dir = (graph.node_pos(neighbor) - center).normalize_or_zero();
210            if dir.length_squared() < 1e-12 {
211                continue;
212            }
213            let right = Vec2::new(-dir.y, dir.x);
214            let hw = match edge.road_type {
215                RoadType::Major => config.major_half_width,
216                RoadType::Minor => config.minor_half_width,
217            };
218            arms.push((eid, dir, right, hw));
219        }
220
221        if arms.is_empty() {
222            continue;
223        }
224
225        // Dead end: simple cap radius.
226        if deg == 1 {
227            let (eid, _, _, hw) = arms[0];
228            truncations.insert((nid, eid), hw + config.curb_radius);
229            continue;
230        }
231
232        // Intersection (degree 3+): sort arms by angle. Treat any NaN
233        // angle as Equal so degenerate fan inputs don't panic.
234        arms.sort_by(|a, b| {
235            let angle_a = (-a.1.y).atan2(a.1.x);
236            let angle_b = (-b.1.y).atan2(b.1.x);
237            angle_a
238                .partial_cmp(&angle_b)
239                .unwrap_or(std::cmp::Ordering::Equal)
240        });
241
242        let n = arms.len();
243        // Initialize truncations with a minimum (half_width ensures some volume).
244        let mut trunc: Vec<f32> = arms.iter().map(|a| a.3).collect();
245
246        // For each adjacent pair, intersect their outer boundary lines.
247        for i in 0..n {
248            let j = (i + 1) % n;
249
250            let (_eid_a, dir_a, right_a, hw_a) = arms[i];
251            let (_eid_b, dir_b, right_b, hw_b) = arms[j];
252
253            // Total boundary width (asphalt + skirt).
254            let w_a = hw_a + skirt_w;
255            let w_b = hw_b + skirt_w;
256
257            // Edge A's left boundary: center - right_A * w_A + dir_A * t_A
258            // Edge B's right boundary: center + right_B * w_B + dir_B * t_B
259            //
260            // Setting equal:
261            //   -right_A * w_A + dir_A * t_A = right_B * w_B + dir_B * t_B
262            //   dir_A * t_A - dir_B * t_B = right_A * w_A + right_B * w_B
263            //
264            // 2x2 system: [dir_A.x  -dir_B.x] [t_A]   [right_A.x * w_A + right_B.x * w_B]
265            //              [dir_A.y  -dir_B.y] [t_B] = [right_A.y * w_A + right_B.y * w_B]
266            let rhs = right_a * w_a + right_b * w_b;
267            let det = dir_a.x * (-dir_b.y) - (-dir_b.x) * dir_a.y;
268
269            if det.abs() < 1e-6 {
270                // Nearly parallel — use a generous fallback.
271                let fallback = (w_a + w_b) * 0.5;
272                trunc[i] = trunc[i].max(fallback);
273                trunc[j] = trunc[j].max(fallback);
274                continue;
275            }
276
277            let t_a = (rhs.x * (-dir_b.y) - (-dir_b.x) * rhs.y) / det;
278            let t_b = (dir_a.x * rhs.y - dir_a.y * rhs.x) / det;
279
280            // Only use positive truncations (intersection is in front).
281            if t_a > 0.0 {
282                trunc[i] = trunc[i].max(t_a);
283            }
284            if t_b > 0.0 {
285                trunc[j] = trunc[j].max(t_b);
286            }
287        }
288
289        // Store results.
290        for (idx, &(eid, _, _, _)) in arms.iter().enumerate() {
291            truncations.insert((nid, eid), trunc[idx]);
292        }
293    }
294
295    truncations
296}
297
298// ---------------------------------------------------------------------------
299// Dead-end cap (N-gon, degree 1)
300// ---------------------------------------------------------------------------
301
302fn generate_hub_cap(
303    graph: &RoadGraph,
304    node_id: NodeId,
305    heightmap: &HeightMap,
306    config: &RoadMeshConfig,
307) -> (ProceduralMesh, ProceduralMesh) {
308    let center = graph.node_pos(node_id);
309    let node = &graph.nodes[node_id as usize];
310
311    // Find max half-width of connecting active edges + curb radius.
312    let mut radius = config.minor_half_width;
313    for &eid in &node.edges {
314        let edge = &graph.edges[eid as usize];
315        if !edge.active {
316            continue;
317        }
318        let hw = match edge.road_type {
319            RoadType::Major => config.major_half_width,
320            RoadType::Minor => config.minor_half_width,
321        };
322        if hw > radius {
323            radius = hw;
324        }
325    }
326    radius += config.curb_radius;
327
328    let sides = config.hub_sides.max(3);
329    let center_y = node.elevation + config.depth_bias;
330
331    let mut mesh = ProceduralMesh::default();
332
333    // Center vertex.
334    mesh.vertices.push([center.x, center_y, center.y]);
335    mesh.normals.push([0.0, 1.0, 0.0]);
336    mesh.uvs.push([
337        center.x * config.texture_scale,
338        center.y * config.texture_scale,
339    ]);
340
341    // Perimeter vertices.
342    let angle_step = std::f32::consts::TAU / sides as f32;
343    let mut perimeter_pts = Vec::with_capacity(sides as usize);
344    for i in 0..sides {
345        let angle = angle_step * i as f32;
346        let (sin, cos) = angle.sin_cos();
347        let px = center.x + cos * radius;
348        let pz = center.y + sin * radius;
349
350        mesh.vertices.push([px, center_y, pz]);
351        mesh.normals.push([0.0, 1.0, 0.0]);
352        mesh.uvs
353            .push([px * config.texture_scale, pz * config.texture_scale]);
354        perimeter_pts.push((px, pz));
355    }
356
357    // Fan triangles.
358    for i in 0..sides {
359        let a = 1 + i;
360        let b = 1 + (i + 1) % sides;
361        mesh.indices.push(0);
362        mesh.indices.push(b);
363        mesh.indices.push(a);
364    }
365
366    // Skirt ring.
367    let skirt_w = config.skirt.width;
368    let bury = config.skirt.bury_depth;
369    let mut skirt = ProceduralMesh::default();
370
371    for &(px, pz) in &perimeter_pts {
372        let dx = px - center.x;
373        let dz = pz - center.y;
374        let len = (dx * dx + dz * dz).sqrt().max(1e-6);
375        let nx = dx / len;
376        let nz = dz / len;
377
378        let outer_x = px + nx * skirt_w;
379        let outer_z = pz + nz * skirt_w;
380        let outer_y = heightmap.get_height_at(outer_x, outer_z) - bury;
381
382        skirt.vertices.push([px, center_y, pz]);
383        skirt.normals.push([0.0, 1.0, 0.0]);
384        skirt
385            .uvs
386            .push([px * config.texture_scale, pz * config.texture_scale]);
387
388        skirt.vertices.push([outer_x, outer_y, outer_z]);
389        skirt.normals.push([0.0, 1.0, 0.0]);
390        skirt.uvs.push([
391            outer_x * config.texture_scale,
392            outer_z * config.texture_scale,
393        ]);
394    }
395
396    for i in 0..sides {
397        let next = (i + 1) % sides;
398        let i0 = i * 2;
399        let o0 = i * 2 + 1;
400        let i1 = next * 2;
401        let o1 = next * 2 + 1;
402
403        skirt.indices.push(i0);
404        skirt.indices.push(i1);
405        skirt.indices.push(o0);
406
407        skirt.indices.push(o0);
408        skirt.indices.push(i1);
409        skirt.indices.push(o1);
410    }
411
412    (mesh, skirt)
413}
414
415// ---------------------------------------------------------------------------
416// Procedural intersection hub (degree 3+)
417// ---------------------------------------------------------------------------
418
419/// Generates a procedural intersection polygon from the truncated ribbon
420/// corners, plus gap-only skirts between adjacent roads.
421fn generate_hub_procedural(
422    graph: &RoadGraph,
423    node_id: NodeId,
424    truncations: &HashMap<(NodeId, EdgeId), f32>,
425    heightmap: &HeightMap,
426    config: &RoadMeshConfig,
427) -> (ProceduralMesh, ProceduralMesh) {
428    let center = graph.node_pos(node_id);
429    let node = &graph.nodes[node_id as usize];
430    let center_y = node.elevation + config.depth_bias;
431    let skirt_w = config.skirt.width;
432    let bury = config.skirt.bury_depth;
433
434    // Collect active edges with geometry.
435    struct Arm {
436        dir: Vec2,
437        right: Vec2,
438        half_width: f32,
439        truncation: f32,
440        angle: f32,
441    }
442
443    let mut arms: Vec<Arm> = Vec::new();
444    for &eid in &node.edges {
445        let edge = &graph.edges[eid as usize];
446        if !edge.active {
447            continue;
448        }
449        let neighbor = graph.opposite(eid, node_id);
450        let dir = (graph.node_pos(neighbor) - center).normalize_or_zero();
451        if dir.length_squared() < 1e-12 {
452            continue;
453        }
454        let right = Vec2::new(-dir.y, dir.x);
455        let hw = match edge.road_type {
456            RoadType::Major => config.major_half_width,
457            RoadType::Minor => config.minor_half_width,
458        };
459        let trunc = truncations
460            .get(&(node_id, eid))
461            .copied()
462            .unwrap_or(hw + config.curb_radius);
463
464        arms.push(Arm {
465            dir,
466            right,
467            half_width: hw,
468            truncation: trunc,
469            angle: (-dir.y).atan2(dir.x),
470        });
471    }
472
473    if arms.is_empty() {
474        return (ProceduralMesh::default(), ProceduralMesh::default());
475    }
476
477    // Sort by angle (CCW). NaN-safe.
478    arms.sort_by(|a, b| {
479        a.angle
480            .partial_cmp(&b.angle)
481            .unwrap_or(std::cmp::Ordering::Equal)
482    });
483
484    // Build perimeter: for each arm, emit right corner then left corner.
485    // Going CCW, the perimeter order is:
486    //   arm[0].right, arm[0].left, arm[1].right, arm[1].left, ...
487    let mut perimeter: Vec<Vec2> = Vec::with_capacity(arms.len() * 2);
488    for arm in &arms {
489        let right_corner = center + arm.dir * arm.truncation + arm.right * arm.half_width;
490        let left_corner = center + arm.dir * arm.truncation - arm.right * arm.half_width;
491        perimeter.push(right_corner);
492        perimeter.push(left_corner);
493    }
494
495    // --- Asphalt mesh: triangle fan from center ---
496    let mut mesh = ProceduralMesh::default();
497
498    // Center vertex (index 0).
499    mesh.vertices.push([center.x, center_y, center.y]);
500    mesh.normals.push([0.0, 1.0, 0.0]);
501    mesh.uvs.push([
502        center.x * config.texture_scale,
503        center.y * config.texture_scale,
504    ]);
505
506    // Perimeter vertices.
507    for pt in &perimeter {
508        mesh.vertices.push([pt.x, center_y, pt.y]);
509        mesh.normals.push([0.0, 1.0, 0.0]);
510        mesh.uvs
511            .push([pt.x * config.texture_scale, pt.y * config.texture_scale]);
512    }
513
514    // Fan triangles around perimeter (CCW winding for upward-facing +Y normal).
515    let peri_count = perimeter.len() as u32;
516    for i in 0..peri_count {
517        let a = 1 + i;
518        let b = 1 + (i + 1) % peri_count;
519        mesh.indices.push(0);
520        mesh.indices.push(a);
521        mesh.indices.push(b);
522    }
523
524    // --- Skirt mesh: quads only in angular gaps between adjacent roads ---
525    let mut skirt = ProceduralMesh::default();
526    let n_arms = arms.len();
527
528    for i in 0..n_arms {
529        let j = (i + 1) % n_arms;
530
531        // Gap runs from arm[i]'s left corner to arm[j]'s right corner.
532        let left_corner =
533            center + arms[i].dir * arms[i].truncation - arms[i].right * arms[i].half_width;
534        let right_corner =
535            center + arms[j].dir * arms[j].truncation + arms[j].right * arms[j].half_width;
536
537        // Angular span of this gap.
538        let left_angle = arms[i].angle + std::f32::consts::FRAC_PI_2; // left side angle
539        let right_angle = arms[j].angle - std::f32::consts::FRAC_PI_2; // right side angle
540
541        // Compute the angular gap; handle wrap-around.
542        let mut gap_angle = right_angle - left_angle;
543        if gap_angle < 0.0 {
544            gap_angle += std::f32::consts::TAU;
545        }
546        if gap_angle > std::f32::consts::TAU {
547            gap_angle -= std::f32::consts::TAU;
548        }
549
550        // Number of subdivisions for a smooth arc (at least 1).
551        let subdivs = ((gap_angle / (std::f32::consts::FRAC_PI_4)).ceil() as u32).max(1);
552
553        // Generate arc points by interpolating between the two corners.
554        let base_vert = skirt.vertices.len() as u32;
555
556        for s in 0..=subdivs {
557            let t = s as f32 / subdivs as f32;
558
559            // Linearly interpolate inner point along the gap.
560            let inner = left_corner.lerp(right_corner, t);
561
562            // Outer direction: from center through inner, pushed out by skirt_w.
563            let out_dir = (inner - center).normalize_or_zero();
564            let outer = inner + out_dir * skirt_w;
565            let outer_y = heightmap.get_height_at(outer.x, outer.y) - bury;
566
567            skirt.vertices.push([inner.x, center_y, inner.y]);
568            skirt.normals.push([0.0, 1.0, 0.0]);
569            skirt.uvs.push([
570                inner.x * config.texture_scale,
571                inner.y * config.texture_scale,
572            ]);
573
574            skirt.vertices.push([outer.x, outer_y, outer.y]);
575            skirt.normals.push([0.0, 1.0, 0.0]);
576            skirt.uvs.push([
577                outer.x * config.texture_scale,
578                outer.y * config.texture_scale,
579            ]);
580        }
581
582        // Quad strip: connect adjacent cross-sections.
583        for s in 0..subdivs {
584            let i0 = base_vert + s * 2; // inner current
585            let o0 = base_vert + s * 2 + 1; // outer current
586            let i1 = base_vert + (s + 1) * 2;
587            let o1 = base_vert + (s + 1) * 2 + 1;
588
589            skirt.indices.push(i0);
590            skirt.indices.push(i1);
591            skirt.indices.push(o0);
592
593            skirt.indices.push(o0);
594            skirt.indices.push(i1);
595            skirt.indices.push(o1);
596        }
597    }
598
599    (mesh, skirt)
600}
601
602// ---------------------------------------------------------------------------
603// Chain extraction (delegated to topology module)
604// ---------------------------------------------------------------------------
605
606/// Local alias — the ribbon generator needs nodes, road type, and edge IDs.
607struct Chain {
608    nodes: Vec<NodeId>,
609    edges: Vec<EdgeId>,
610    road_type: RoadType,
611}
612
613fn extract_chains(graph: &RoadGraph, degrees: &[u32]) -> Vec<Chain> {
614    topology::extract_chains(graph, degrees)
615        .into_iter()
616        .map(|c| Chain {
617            nodes: c.nodes,
618            edges: c.edges,
619            road_type: c.road_type,
620        })
621        .collect()
622}
623
624// ---------------------------------------------------------------------------
625// Ribbon generation
626// ---------------------------------------------------------------------------
627
628fn generate_ribbon(
629    graph: &RoadGraph,
630    chain: &Chain,
631    truncations: &HashMap<(NodeId, EdgeId), f32>,
632    heightmap: &HeightMap,
633    config: &RoadMeshConfig,
634) -> (ProceduralMesh, ProceduralMesh) {
635    let half_width = match chain.road_type {
636        RoadType::Major => config.major_half_width,
637        RoadType::Minor => config.minor_half_width,
638    };
639
640    let smooth_pts: Vec<Vec2> = chain.nodes.iter().map(|&nid| graph.node_pos(nid)).collect();
641    let node_elevs: Vec<f32> = chain
642        .nodes
643        .iter()
644        .map(|&nid| graph.nodes[nid as usize].elevation)
645        .collect();
646    if smooth_pts.len() < 2 {
647        return (ProceduralMesh::default(), ProceduralMesh::default());
648    }
649
650    // Look up truncation at each endpoint from the precomputed map.
651    // Invariant: smooth_pts is built from chain.nodes; we returned early
652    // when smooth_pts.len() < 2, so chain.nodes/edges are non-empty.
653    let first_node = chain.nodes[0];
654    let last_node = chain.nodes[chain.nodes.len() - 1];
655    let first_edge = chain.edges[0];
656    let last_edge = chain.edges[chain.edges.len() - 1];
657
658    let start_trim = truncations
659        .get(&(first_node, first_edge))
660        .copied()
661        .unwrap_or(0.0);
662    let end_trim = truncations
663        .get(&(last_node, last_edge))
664        .copied()
665        .unwrap_or(0.0);
666
667    let (truncated, truncated_elevs) =
668        truncate_polyline_with_elevations(&smooth_pts, &node_elevs, start_trim, end_trim);
669    if truncated.len() < 2 {
670        return (ProceduralMesh::default(), ProceduralMesh::default());
671    }
672
673    extrude_ribbon(&truncated, &truncated_elevs, half_width, heightmap, config)
674}
675
676/// Truncates a polyline and its associated elevations by removing length
677/// from the start and end, interpolating elevations at the cut points.
678fn truncate_polyline_with_elevations(
679    points: &[Vec2],
680    elevations: &[f32],
681    start_trim: f32,
682    end_trim: f32,
683) -> (Vec<Vec2>, Vec<f32>) {
684    if points.len() < 2 {
685        return (points.to_vec(), elevations.to_vec());
686    }
687
688    let mut arc_lengths = Vec::with_capacity(points.len());
689    arc_lengths.push(0.0f32);
690    for i in 1..points.len() {
691        let seg_len = (points[i] - points[i - 1]).length();
692        arc_lengths.push(arc_lengths[i - 1] + seg_len);
693    }
694    // points.len() >= 2 (guarded above), so arc_lengths is non-empty.
695    let total = arc_lengths[arc_lengths.len() - 1];
696
697    // Clamp combined trim so it never exceeds 98% of the segment length.
698    let max_trim = total * 0.98;
699    let (adj_start, adj_end) = if start_trim + end_trim > max_trim {
700        let scale = max_trim / (start_trim + end_trim);
701        (start_trim * scale, end_trim * scale)
702    } else {
703        (start_trim, end_trim)
704    };
705
706    let t_start = adj_start;
707    let t_end = total - adj_end;
708    if t_start >= t_end {
709        return (Vec::new(), Vec::new());
710    }
711
712    let mut result_pts = Vec::new();
713    let mut result_elevs = Vec::new();
714
715    result_pts.push(point_at_arc_length(points, &arc_lengths, t_start));
716    result_elevs.push(elevation_at_arc_length(elevations, &arc_lengths, t_start));
717
718    for i in 1..points.len() - 1 {
719        if arc_lengths[i] > t_start && arc_lengths[i] < t_end {
720            result_pts.push(points[i]);
721            result_elevs.push(elevations[i]);
722        }
723    }
724
725    result_pts.push(point_at_arc_length(points, &arc_lengths, t_end));
726    result_elevs.push(elevation_at_arc_length(elevations, &arc_lengths, t_end));
727
728    (result_pts, result_elevs)
729}
730
731/// Returns the interpolated elevation at a given arc length along a polyline.
732///
733/// Returns `0.0` if `elevations` is empty, which only happens on programmer
734/// error (callers always pass parallel slices to `points`/`arc_lengths`).
735fn elevation_at_arc_length(elevations: &[f32], arc_lengths: &[f32], target: f32) -> f32 {
736    for i in 1..elevations.len() {
737        if arc_lengths[i] >= target {
738            let seg_len = arc_lengths[i] - arc_lengths[i - 1];
739            if seg_len < 1e-6 {
740                return elevations[i];
741            }
742            let t = (target - arc_lengths[i - 1]) / seg_len;
743            return elevations[i - 1] + t * (elevations[i] - elevations[i - 1]);
744        }
745    }
746    elevations.last().copied().unwrap_or(0.0)
747}
748
749/// Returns the 2D point at a given arc length along a polyline.
750///
751/// Returns `Vec2::ZERO` if `points` is empty (caller-side invariant).
752fn point_at_arc_length(points: &[Vec2], arc_lengths: &[f32], target: f32) -> Vec2 {
753    for i in 1..points.len() {
754        if arc_lengths[i] >= target {
755            let seg_len = arc_lengths[i] - arc_lengths[i - 1];
756            if seg_len < 1e-6 {
757                return points[i];
758            }
759            let t = (target - arc_lengths[i - 1]) / seg_len;
760            return points[i - 1].lerp(points[i], t);
761        }
762    }
763    points.last().copied().unwrap_or(Vec2::ZERO)
764}
765
766/// Extrudes a 2D polyline into a flat asphalt ribbon and tapered embankment
767/// skirt meshes.
768fn extrude_ribbon(
769    points: &[Vec2],
770    elevations: &[f32],
771    half_width: f32,
772    heightmap: &HeightMap,
773    config: &RoadMeshConfig,
774) -> (ProceduralMesh, ProceduralMesh) {
775    let n = points.len();
776    let skirt_w = config.skirt.width;
777    let bury = config.skirt.bury_depth;
778
779    let mut asphalt = ProceduralMesh {
780        vertices: Vec::with_capacity(n * 2),
781        normals: Vec::with_capacity(n * 2),
782        uvs: Vec::with_capacity(n * 2),
783        indices: Vec::with_capacity((n - 1) * 6),
784    };
785
786    let mut skirts = ProceduralMesh {
787        vertices: Vec::with_capacity(n * 4),
788        normals: Vec::with_capacity(n * 4),
789        uvs: Vec::with_capacity(n * 4),
790        indices: Vec::with_capacity((n - 1) * 12),
791    };
792
793    let mut accum_dist = 0.0f32;
794
795    for i in 0..n {
796        let tangent = if i == 0 {
797            (points[1] - points[0]).normalize_or_zero()
798        } else if i == n - 1 {
799            (points[n - 1] - points[n - 2]).normalize_or_zero()
800        } else {
801            (points[i + 1] - points[i - 1]).normalize_or_zero()
802        };
803
804        let right = Vec2::new(-tangent.y, tangent.x);
805
806        let left_pt = points[i] - right * half_width;
807        let right_pt = points[i] + right * half_width;
808
809        let center_y = elevations[i] + config.depth_bias;
810
811        let elev_delta = if i == 0 {
812            elevations[1] - elevations[0]
813        } else if i == n - 1 {
814            elevations[n - 1] - elevations[n - 2]
815        } else {
816            elevations[i + 1] - elevations[i - 1]
817        };
818        let forward_3d = Vec3::new(tangent.x, elev_delta, tangent.y).normalize_or_zero();
819        let right_3d = Vec3::new(right.x, 0.0, right.y);
820        let normal = right_3d.cross(forward_3d).normalize_or_zero();
821        let normal = if normal.y < 0.0 { -normal } else { normal };
822        let norm_arr = [normal.x, normal.y, normal.z];
823
824        asphalt.vertices.push([right_pt.x, center_y, right_pt.y]);
825        asphalt.vertices.push([left_pt.x, center_y, left_pt.y]);
826        asphalt.normals.push(norm_arr);
827        asphalt.normals.push(norm_arr);
828
829        if i > 0 {
830            accum_dist += (points[i] - points[i - 1]).length();
831        }
832        let u = accum_dist * config.texture_scale;
833        asphalt.uvs.push([u, 0.0]);
834        asphalt.uvs.push([u, 1.0]);
835
836        let right_outer_pt = points[i] + right * (half_width + skirt_w);
837        let left_outer_pt = points[i] - right * (half_width + skirt_w);
838
839        let right_outer_y = heightmap.get_height_at(right_outer_pt.x, right_outer_pt.y) - bury;
840        let left_outer_y = heightmap.get_height_at(left_outer_pt.x, left_outer_pt.y) - bury;
841
842        skirts.vertices.push([right_pt.x, center_y, right_pt.y]);
843        skirts
844            .vertices
845            .push([right_outer_pt.x, right_outer_y, right_outer_pt.y]);
846        skirts.vertices.push([left_pt.x, center_y, left_pt.y]);
847        skirts
848            .vertices
849            .push([left_outer_pt.x, left_outer_y, left_outer_pt.y]);
850
851        skirts.normals.push([0.0, 1.0, 0.0]);
852        skirts.normals.push([0.0, 1.0, 0.0]);
853        skirts.normals.push([0.0, 1.0, 0.0]);
854        skirts.normals.push([0.0, 1.0, 0.0]);
855
856        let skirt_u = u;
857        skirts.uvs.push([skirt_u, 0.0]);
858        skirts.uvs.push([skirt_u, 1.0]);
859        skirts.uvs.push([skirt_u, 0.0]);
860        skirts.uvs.push([skirt_u, 1.0]);
861    }
862
863    // Asphalt index buffer.
864    for i in 0..n as u32 - 1 {
865        let bl = i * 2;
866        let br = i * 2 + 1;
867        let tl = (i + 1) * 2;
868        let tr = (i + 1) * 2 + 1;
869
870        asphalt.indices.push(bl);
871        asphalt.indices.push(tl);
872        asphalt.indices.push(br);
873
874        asphalt.indices.push(br);
875        asphalt.indices.push(tl);
876        asphalt.indices.push(tr);
877    }
878
879    // Skirt index buffer.
880    for i in 0..n as u32 - 1 {
881        let base = i * 4;
882        let next = (i + 1) * 4;
883
884        let ri0 = base;
885        let ro0 = base + 1;
886        let ri1 = next;
887        let ro1 = next + 1;
888
889        skirts.indices.push(ri0);
890        skirts.indices.push(ro0);
891        skirts.indices.push(ri1);
892        skirts.indices.push(ro0);
893        skirts.indices.push(ro1);
894        skirts.indices.push(ri1);
895
896        let li0 = base + 2;
897        let lo0 = base + 3;
898        let li1 = next + 2;
899        let lo1 = next + 3;
900
901        skirts.indices.push(li0);
902        skirts.indices.push(li1);
903        skirts.indices.push(lo0);
904        skirts.indices.push(lo0);
905        skirts.indices.push(li1);
906        skirts.indices.push(lo1);
907    }
908
909    (asphalt, skirts)
910}
911
912// ---------------------------------------------------------------------------
913// Tests
914// ---------------------------------------------------------------------------
915
916#[cfg(test)]
917mod tests {
918    use super::*;
919    use crate::graph::RoadGraph;
920
921    /// Helper: builds a simple cross intersection graph.
922    fn cross_graph() -> RoadGraph {
923        let mut g = RoadGraph::default();
924        // Center node
925        let c = g.add_node(Vec2::new(50.0, 50.0));
926        // Four arms
927        let n = g.add_node(Vec2::new(50.0, 20.0));
928        let s = g.add_node(Vec2::new(50.0, 80.0));
929        let e = g.add_node(Vec2::new(80.0, 50.0));
930        let w = g.add_node(Vec2::new(20.0, 50.0));
931
932        g.add_edge(c, n, RoadType::Major);
933        g.add_edge(c, s, RoadType::Major);
934        g.add_edge(c, e, RoadType::Minor);
935        g.add_edge(c, w, RoadType::Minor);
936
937        g
938    }
939
940    fn flat_heightmap() -> HeightMap {
941        HeightMap::new(64, 64, 2.0)
942    }
943
944    #[test]
945    fn hub_cap_mesh_has_correct_vertex_count() {
946        // Build a dead-end node (degree 1).
947        let mut g = RoadGraph::default();
948        let a = g.add_node(Vec2::new(50.0, 50.0));
949        let b = g.add_node(Vec2::new(80.0, 50.0));
950        g.add_edge(a, b, RoadType::Major);
951
952        let hm = flat_heightmap();
953        let config = RoadMeshConfig::default();
954
955        // Node `a` has degree 1 → cap.
956        let (hub, hub_skirt) = generate_hub_cap(&g, a, &hm, &config);
957        let sides = config.hub_sides;
958        assert_eq!(hub.vertices.len(), (1 + sides) as usize);
959        assert_eq!(hub.indices.len(), (sides * 3) as usize);
960        assert_eq!(hub_skirt.vertices.len(), (sides * 2) as usize);
961        assert_eq!(hub_skirt.indices.len(), (sides * 6) as usize);
962    }
963
964    #[test]
965    fn procedural_hub_has_correct_vertex_count() {
966        let g = cross_graph();
967        let hm = flat_heightmap();
968        let config = RoadMeshConfig::default();
969        let degrees = compute_active_degrees(&g);
970        let truncations = compute_truncations(&g, &degrees, &config);
971
972        // Center node (0) has degree 4 → procedural hub.
973        let (hub, _hub_skirt) = generate_hub_procedural(&g, 0, &truncations, &hm, &config);
974        // 1 center + 4 arms × 2 corners = 9 vertices.
975        assert_eq!(hub.vertices.len(), 1 + 4 * 2);
976        // 8 fan triangles × 3 indices = 24.
977        assert_eq!(hub.indices.len(), 8 * 3);
978    }
979
980    #[test]
981    fn truncations_computed_for_all_edges() {
982        let g = cross_graph();
983        let degrees = compute_active_degrees(&g);
984        let config = RoadMeshConfig::default();
985        let truncations = compute_truncations(&g, &degrees, &config);
986
987        // Center node (0) has 4 active edges.
988        for eid in 0..4u32 {
989            assert!(
990                truncations.contains_key(&(0, eid)),
991                "truncation missing for (0, {eid})"
992            );
993            let t = truncations[&(0, eid)];
994            assert!(t > 0.0, "truncation should be positive, got {t}");
995        }
996
997        // Leaf nodes (1..4) each have 1 edge → dead-end truncation.
998        for nid in 1..5u32 {
999            let node = &g.nodes[nid as usize];
1000            for &eid in &node.edges {
1001                assert!(
1002                    truncations.contains_key(&(nid, eid)),
1003                    "truncation missing for ({nid}, {eid})"
1004                );
1005            }
1006        }
1007    }
1008
1009    #[test]
1010    fn ribbon_mesh_nonempty() {
1011        let mut g = RoadGraph::default();
1012        let a = g.add_node(Vec2::new(10.0, 10.0));
1013        let b = g.add_node(Vec2::new(90.0, 10.0));
1014        g.add_edge(a, b, RoadType::Major);
1015
1016        let hm = flat_heightmap();
1017        let config = RoadMeshConfig::default();
1018        let meshes = generate_road_meshes(&g, &hm, &config);
1019
1020        assert!(!meshes.hubs.vertices.is_empty());
1021        assert!(!meshes.ribbons.vertices.is_empty());
1022        assert_eq!(meshes.ribbons.indices.len() % 3, 0);
1023    }
1024
1025    #[test]
1026    fn truncate_polyline_shortens() {
1027        let pts = vec![
1028            Vec2::new(0.0, 0.0),
1029            Vec2::new(10.0, 0.0),
1030            Vec2::new(20.0, 0.0),
1031        ];
1032        let elevs = vec![0.0, 5.0, 10.0];
1033        let (truncated, trunc_elevs) = truncate_polyline_with_elevations(&pts, &elevs, 3.0, 3.0);
1034        assert!(!truncated.is_empty());
1035        assert!((truncated[0].x - 3.0).abs() < 1e-4);
1036        assert!((truncated.last().unwrap().x - 17.0).abs() < 1e-4);
1037        assert!((trunc_elevs[0] - 1.5).abs() < 1e-4);
1038        assert!((*trunc_elevs.last().unwrap() - 8.5).abs() < 1e-4);
1039    }
1040
1041    #[test]
1042    fn full_pipeline_cross_graph() {
1043        let g = cross_graph();
1044        let hm = flat_heightmap();
1045        let config = RoadMeshConfig::default();
1046
1047        let meshes = generate_road_meshes(&g, &hm, &config);
1048
1049        let degrees = compute_active_degrees(&g);
1050        let hub_count = degrees.iter().filter(|&&d| d > 0 && d != 2).count();
1051        assert_eq!(hub_count, 5);
1052
1053        assert!(!meshes.hubs.vertices.is_empty());
1054        assert!(!meshes.ribbons.vertices.is_empty());
1055    }
1056}