Skip to main content

symbios_tensor/
tracer.rs

1//! Streamline tracer — the core road generation algorithm.
2//!
3//! Seeds are placed on a jittered grid and traced bidirectionally (±major,
4//! ±minor) through the tensor field using RK2 (midpoint) integration.
5//! Traces snap to existing nodes and edges via the spatial hash, creating
6//! T-junctions and 4-way intersections. Orthogonal branches are spawned at
7//! configurable intervals to fill the network.
8
9use std::collections::VecDeque;
10use std::fmt;
11
12use glam::Vec2;
13use rand::Rng;
14use rand_pcg::Pcg64;
15use serde::{Deserialize, Serialize};
16use symbios_ground::HeightMap;
17
18use crate::graph::{RoadGraph, RoadType};
19use crate::spatial::{SpatialHash, TraceResult, resolve_trace_step};
20use crate::tensor::{TensorField, TensorFieldConfig};
21
22/// Pipeline stage that produced a [`GenerationError`].
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum GenerationStage {
25    /// Configuration validation in [`generate_roads`].
26    Config,
27    /// Streamline tracing.
28    Tracer,
29    /// Tensor field sampling.
30    Tensor,
31    /// Graph rationalization (RDP, fillet, elevation smoothing).
32    Rationalize,
33    /// Block polygon extraction.
34    Polygons,
35    /// Building lot extraction.
36    Lots,
37    /// Heightmap carving.
38    Carve,
39    /// Road pruning.
40    Prune,
41    /// 3D road mesh generation.
42    Roads3d,
43}
44
45impl fmt::Display for GenerationStage {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        let s = match self {
48            Self::Config => "config",
49            Self::Tracer => "tracer",
50            Self::Tensor => "tensor",
51            Self::Rationalize => "rationalize",
52            Self::Polygons => "polygons",
53            Self::Lots => "lots",
54            Self::Carve => "carve",
55            Self::Prune => "prune",
56            Self::Roads3d => "roads_3d",
57        };
58        f.write_str(s)
59    }
60}
61
62/// Top-level error type for the city generation pipeline.
63///
64/// Every public entry point that can fail returns
65/// [`Result<_, GenerationError>`]. The variants distinguish recoverable
66/// configuration errors from degenerate input geometry and from internal
67/// invariant violations.
68#[derive(Debug, Clone)]
69pub enum GenerationError {
70    /// A configuration parameter was non-finite, non-positive, or otherwise
71    /// out of the documented valid range.
72    InvalidConfig {
73        /// Pipeline stage that rejected the config.
74        stage: GenerationStage,
75        /// Human-readable description.
76        message: String,
77    },
78    /// Input geometry was degenerate (e.g. NaN coordinates, collinear
79    /// polygon, empty input where one was required).
80    DegenerateInput {
81        /// Pipeline stage that detected the problem.
82        stage: GenerationStage,
83        /// Human-readable description.
84        message: String,
85    },
86    /// Numerical edge case prevented the algorithm from making progress.
87    Numerical {
88        /// Pipeline stage that detected the problem.
89        stage: GenerationStage,
90        /// Human-readable description.
91        message: String,
92    },
93}
94
95impl fmt::Display for GenerationError {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        match self {
98            Self::InvalidConfig { stage, message } => {
99                write!(f, "[{stage}] invalid config: {message}")
100            }
101            Self::DegenerateInput { stage, message } => {
102                write!(f, "[{stage}] degenerate input: {message}")
103            }
104            Self::Numerical { stage, message } => {
105                write!(f, "[{stage}] numerical failure: {message}")
106            }
107        }
108    }
109}
110
111impl std::error::Error for GenerationError {}
112
113/// Configuration for tensor-field city generation.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct TensorConfig {
116    /// RNG seed for jittered seed placement.
117    pub seed: u64,
118    /// World-space distance per integration step.
119    pub step_size: f32,
120    /// Desired spacing between parallel major roads (avenues).
121    pub major_road_dist: f32,
122    /// Desired spacing between parallel minor roads (streets).
123    pub minor_road_dist: f32,
124    /// Snap radius for merging trace endpoints with existing geometry.
125    pub snap_radius: f32,
126    /// Maximum number of integration steps per trace before it is abandoned.
127    pub max_trace_steps: u32,
128    /// Momentum factor for the tracer direction (range `0.0` to `0.99`).
129    /// Higher values resist sharp direction changes caused by high-frequency
130    /// terrain noise, producing smoother, less zig-zaggy roads.
131    pub tracer_inertia: f32,
132    /// Absolute world-space Y coordinate for the water plane.
133    /// Terrain at or below this height is treated as underwater.
134    /// Defaults to [`f32::NEG_INFINITY`] (no water).
135    pub water_level: f32,
136    /// Tensor field sampling configuration (slope thresholds, jitter).
137    pub field: TensorFieldConfig,
138}
139
140impl Default for TensorConfig {
141    fn default() -> Self {
142        Self {
143            seed: 42,
144            step_size: 2.0,
145            major_road_dist: 40.0,
146            minor_road_dist: 15.0,
147            snap_radius: 4.0,
148            max_trace_steps: 300,
149            tracer_inertia: 0.8,
150            water_level: f32::NEG_INFINITY,
151            field: TensorFieldConfig::default(),
152        }
153    }
154}
155
156#[derive(Debug, Clone, Copy)]
157struct Seed {
158    position: Vec2,
159    direction: Vec2,
160    road_type: RoadType,
161    /// Accumulated distance since the last orthogonal branch was spawned.
162    branch_accum: f32,
163    /// If set, reuse this existing node instead of creating a new one at `position`.
164    existing_node: Option<u32>,
165}
166
167/// Generates a [`RoadGraph`] by tracing streamlines through the tensor field
168/// derived from the given heightmap.
169///
170/// # Errors
171///
172/// Returns [`GenerationError::InvalidConfig`] if any `TensorConfig` parameter
173/// is non-finite or non-positive (step_size, major_road_dist, minor_road_dist,
174/// snap_radius must all be > 0).
175pub fn generate_roads(
176    heightmap: &HeightMap,
177    config: &TensorConfig,
178) -> Result<RoadGraph, GenerationError> {
179    let cfg_err = |message: String| GenerationError::InvalidConfig {
180        stage: GenerationStage::Config,
181        message,
182    };
183    if !config.step_size.is_finite() || config.step_size <= 0.0 {
184        return Err(cfg_err(format!(
185            "step_size must be finite and positive, got {}",
186            config.step_size
187        )));
188    }
189    if !config.major_road_dist.is_finite() || config.major_road_dist <= 0.0 {
190        return Err(cfg_err(format!(
191            "major_road_dist must be finite and positive, got {}",
192            config.major_road_dist
193        )));
194    }
195    if !config.minor_road_dist.is_finite() || config.minor_road_dist <= 0.0 {
196        return Err(cfg_err(format!(
197            "minor_road_dist must be finite and positive, got {}",
198            config.minor_road_dist
199        )));
200    }
201    if !config.snap_radius.is_finite() || config.snap_radius <= 0.0 {
202        return Err(cfg_err(format!(
203            "snap_radius must be finite and positive, got {}",
204            config.snap_radius
205        )));
206    }
207
208    let field = TensorField::with_config(heightmap, config.field.clone());
209    let mut graph = RoadGraph::default();
210
211    let world_w = heightmap.world_width();
212    let world_d = heightmap.world_depth();
213    let cell_size = config.snap_radius * 2.0;
214    let mut spatial = SpatialHash::new(world_w, world_d, cell_size);
215
216    let mut rng = Pcg64::new(config.seed.into(), 0xa02bdbf7bb3c0a7_u128);
217
218    // --- Seed generation ---
219    // Drop seeds along a grid at `major_road_dist` spacing, jittered slightly.
220    let mut active: VecDeque<Seed> = VecDeque::new();
221
222    let margin = config.major_road_dist * 0.5;
223    let mut x = margin;
224    while x < world_w - margin {
225        let mut z = margin;
226        while z < world_d - margin {
227            let jitter_x: f32 = rng.random_range(-config.step_size..config.step_size);
228            let jitter_z: f32 = rng.random_range(-config.step_size..config.step_size);
229            let pos = Vec2::new(x + jitter_x, z + jitter_z);
230
231            // Do not spawn seeds underwater (at or below water level)
232            if heightmap.get_height_at(pos.x, pos.y) <= config.water_level {
233                z += config.major_road_dist;
234                continue;
235            }
236
237            let (major, minor) = field.sample(pos.x, pos.y);
238
239            // Create a shared starting node for all traces from this seed
240            let elev = heightmap.get_height_at(pos.x, pos.y);
241            let shared_node = graph.add_node_with_elevation(pos, elev);
242            spatial.insert_node(shared_node, pos);
243
244            // Trace both directions along each axis to form full through-lines
245            for &dir in &[major, -major] {
246                active.push_back(Seed {
247                    position: pos,
248                    direction: dir,
249                    road_type: RoadType::Major,
250                    branch_accum: 0.0,
251                    existing_node: Some(shared_node),
252                });
253            }
254            for &dir in &[minor, -minor] {
255                active.push_back(Seed {
256                    position: pos,
257                    direction: dir,
258                    road_type: RoadType::Minor,
259                    branch_accum: 0.0,
260                    existing_node: Some(shared_node),
261                });
262            }
263
264            z += config.major_road_dist;
265        }
266        x += config.major_road_dist;
267    }
268
269    // --- Trace each seed ---
270    let bounds = Vec2::new(world_w, world_d);
271    // Cap total traces to prevent runaway branching in circular tensor flows.
272    // Use the larger of seed-proportional and area-proportional limits so that
273    // sparse seeds on large maps don't prematurely abort city growth.
274    let area_based = ((world_w * world_d) / config.minor_road_dist) as usize;
275    let max_traces = (active.len() * 50).max(area_based);
276    let mut trace_count = 0_usize;
277    while let Some(seed) = active.pop_front() {
278        trace_count += 1;
279        if trace_count > max_traces {
280            break;
281        }
282        trace_streamline(
283            &field,
284            &mut graph,
285            &mut spatial,
286            &mut active,
287            seed,
288            config,
289            bounds,
290        );
291    }
292
293    Ok(graph)
294}
295
296fn trace_streamline(
297    field: &TensorField<'_>,
298    graph: &mut RoadGraph,
299    spatial: &mut SpatialHash,
300    active: &mut VecDeque<Seed>,
301    seed: Seed,
302    config: &TensorConfig,
303    bounds: Vec2,
304) {
305    let start_node = match seed.existing_node {
306        Some(id) => id,
307        None => {
308            let elev = field
309                .heightmap
310                .get_height_at(seed.position.x, seed.position.y);
311            let id = graph.add_node_with_elevation(seed.position, elev);
312            spatial.insert_node(id, seed.position);
313            id
314        }
315    };
316
317    let mut current_node = start_node;
318    let mut dir = seed.direction;
319    let mut branch_accum = seed.branch_accum;
320
321    for _ in 0..config.max_trace_steps {
322        let current_pos = graph.node_pos(current_node);
323
324        // RK2 (midpoint method): sample k1 at current position
325        let (k1_major, k1_minor) = field.sample(current_pos.x, current_pos.y);
326        let k1 = match seed.road_type {
327            RoadType::Major => k1_major,
328            RoadType::Minor => k1_minor,
329        };
330        let k1 = if k1.dot(dir) < 0.0 { -k1 } else { k1 };
331
332        // Sample k2 at midpoint. If the midpoint falls outside world bounds
333        // (tracer near the edge), degrade to forward-Euler for this step
334        // rather than sampling out-of-bounds terrain.
335        let mid = current_pos + k1 * (config.step_size * 0.5);
336        let k2 = if mid.x >= 0.0 && mid.x < bounds.x && mid.y >= 0.0 && mid.y < bounds.y {
337            let (k2_major, k2_minor) = field.sample(mid.x, mid.y);
338            let k2 = match seed.road_type {
339                RoadType::Major => k2_major,
340                RoadType::Minor => k2_minor,
341            };
342            if k2.dot(k1) < 0.0 { -k2 } else { k2 }
343        } else {
344            k1
345        };
346
347        // Blend with previous direction for momentum (anti-zig-zag).
348        let inertia = config.tracer_inertia.clamp(0.0, 0.99);
349        dir = (dir * inertia + k2 * (1.0 - inertia)).normalize_or_zero();
350        if dir.length_squared() < 1e-12 {
351            break;
352        }
353        let proposed = current_pos + dir * config.step_size;
354
355        // Bounds check (NaN coordinates fail is_finite and abort the trace)
356        if !proposed.x.is_finite()
357            || !proposed.y.is_finite()
358            || proposed.x < 0.0
359            || proposed.x >= bounds.x
360            || proposed.y < 0.0
361            || proposed.y >= bounds.y
362        {
363            break;
364        }
365
366        // Coastline collision. If the proposed step dips underwater, abort the trace.
367        if field.heightmap.get_height_at(proposed.x, proposed.y) <= config.water_level {
368            break;
369        }
370
371        match resolve_trace_step(
372            graph,
373            spatial,
374            current_pos,
375            proposed,
376            config.snap_radius,
377            current_node,
378        ) {
379            TraceResult::Clear(pos) => {
380                let elev = field.heightmap.get_height_at(pos.x, pos.y);
381                let new_node = graph.add_node_with_elevation(pos, elev);
382                spatial.insert_node(new_node, pos);
383                let edge_id = graph.add_edge(current_node, new_node, seed.road_type);
384                spatial.insert_edge(edge_id, current_pos, pos);
385                current_node = new_node;
386            }
387            TraceResult::SnappedToNode(n_id) => {
388                // Avoid duplicate edges
389                let already_connected =
390                    graph.nodes[current_node as usize].edges.iter().any(|&eid| {
391                        let e = &graph.edges[eid as usize];
392                        e.active && (e.start == n_id || e.end == n_id)
393                    });
394                if !already_connected {
395                    let n_pos = graph.node_pos(n_id);
396
397                    // The snapped endpoint may differ from the proposed
398                    // position, rotating the committed segment so it crosses
399                    // an edge the original ray missed. Re-check the adjusted
400                    // trajectory for crossings to maintain planarity.
401                    let crossing =
402                        find_crossing(graph, spatial, current_pos, n_pos, current_node, n_id);
403                    if let Some((cross_eid, cross_pt)) = crossing {
404                        let mid_node = split_or_snap_edge(graph, spatial, cross_eid, cross_pt);
405                        // split_or_snap_edge may return an existing endpoint;
406                        // guard against creating a duplicate edge.
407                        let already = graph.nodes[current_node as usize].edges.iter().any(|&eid| {
408                            let e = &graph.edges[eid as usize];
409                            e.active && (e.start == mid_node || e.end == mid_node)
410                        });
411                        if !already {
412                            let mid_pos = graph.node_pos(mid_node);
413                            let connecting = graph.add_edge(current_node, mid_node, seed.road_type);
414                            spatial.insert_edge(connecting, current_pos, mid_pos);
415                        }
416                    } else {
417                        let edge_id = graph.add_edge(current_node, n_id, seed.road_type);
418                        spatial.insert_edge(edge_id, current_pos, n_pos);
419                    }
420                }
421                break;
422            }
423            TraceResult::SnappedToEdge {
424                edge_id,
425                intersection_pos,
426            } => {
427                let split_edge = &graph.edges[edge_id as usize];
428                let old_start_pos_pre = graph.node_pos(split_edge.start);
429                let old_end_pos_pre = graph.node_pos(split_edge.end);
430                let edge_dir = (old_end_pos_pre - old_start_pos_pre).normalize_or_zero();
431
432                let mid_node = split_or_snap_edge(graph, spatial, edge_id, intersection_pos);
433                let mid_pos = graph.node_pos(mid_node);
434
435                // The snap target (intersection_pos) can be up to snap_radius
436                // away from proposed_pos, sweeping the connecting segment
437                // through unverified space. Re-check for crossings to
438                // maintain planarity, mirroring the SnappedToNode branch.
439                let crossing =
440                    find_crossing(graph, spatial, current_pos, mid_pos, current_node, mid_node);
441                if let Some((cross_eid, cross_pt)) = crossing {
442                    let cross_mid = split_or_snap_edge(graph, spatial, cross_eid, cross_pt);
443                    // split_or_snap_edge may return an existing endpoint;
444                    // guard against creating a duplicate edge.
445                    let already = graph.nodes[current_node as usize].edges.iter().any(|&eid| {
446                        let e = &graph.edges[eid as usize];
447                        e.active && (e.start == cross_mid || e.end == cross_mid)
448                    });
449                    if !already {
450                        let cross_pos = graph.node_pos(cross_mid);
451                        let connecting_edge =
452                            graph.add_edge(current_node, cross_mid, seed.road_type);
453                        spatial.insert_edge(connecting_edge, current_pos, cross_pos);
454                    }
455                    // Terminate: we hit a crossing before reaching the
456                    // snapped edge, so continuing from mid_node would
457                    // leave a gap in the graph.
458                    break;
459                }
460
461                let connecting_edge = graph.add_edge(current_node, mid_node, seed.road_type);
462                spatial.insert_edge(connecting_edge, graph.node_pos(current_node), mid_pos);
463
464                // If the trace direction is nearly parallel to the edge
465                // we just split, continuing would create overlapping
466                // geometry invisible to resolve_trace_step (the split
467                // halves are connected to mid_node and thus skipped).
468                let alignment = dir.dot(edge_dir).abs();
469                if alignment > 0.9 {
470                    break;
471                }
472
473                // Continue tracing through the intersection so that
474                // 4-way crossings form naturally instead of dead-ending
475                // at every T-junction.
476                current_node = mid_node;
477            }
478        }
479
480        // Branching: spawn an orthogonal trace at regular intervals
481        branch_accum += config.step_size;
482        let branch_dist = match seed.road_type {
483            RoadType::Major => config.minor_road_dist,
484            RoadType::Minor => config.major_road_dist,
485        };
486        if branch_accum >= branch_dist {
487            branch_accum -= branch_dist;
488            let (field_major, field_minor) = field.sample(proposed.x, proposed.y);
489            let branch_dir = match seed.road_type {
490                RoadType::Major => field_minor,
491                RoadType::Minor => field_major,
492            };
493            let branch_type = match seed.road_type {
494                RoadType::Major => RoadType::Minor,
495                RoadType::Minor => RoadType::Major,
496            };
497            for &dir_sign in &[1.0_f32, -1.0] {
498                active.push_back(Seed {
499                    position: graph.node_pos(current_node),
500                    direction: branch_dir * dir_sign,
501                    road_type: branch_type,
502                    branch_accum: 0.0,
503                    existing_node: Some(current_node),
504                });
505            }
506        }
507    }
508}
509
510/// Minimum squared distance between a split point and an existing endpoint.
511/// If a cross-point falls closer than this to an edge's start or end node,
512/// we snap to that node instead of inserting a degenerate micro-segment.
513const SPLIT_SNAP_DIST_SQ: f32 = 1.0;
514
515/// Splits `edge_id` at `split_pos`, or snaps to an existing endpoint if
516/// `split_pos` is within `SPLIT_SNAP_DIST_SQ` of one. Returns the node
517/// at the split (or snapped endpoint). When a real split occurs the two
518/// new half-edge IDs are returned; when snapping, no new edges are created.
519fn split_or_snap_edge(
520    graph: &mut RoadGraph,
521    spatial: &mut SpatialHash,
522    edge_id: u32,
523    split_pos: Vec2,
524) -> u32 {
525    let edge = &graph.edges[edge_id as usize];
526    let start = edge.start;
527    let end = edge.end;
528    let start_pos = graph.node_pos(start);
529    let end_pos = graph.node_pos(end);
530
531    // Snap to existing endpoint if the split point is very close
532    if split_pos.distance_squared(start_pos) < SPLIT_SNAP_DIST_SQ {
533        return start;
534    }
535    if split_pos.distance_squared(end_pos) < SPLIT_SNAP_DIST_SQ {
536        return end;
537    }
538
539    let (mid_node, ea, eb) = graph.split_edge(edge_id, split_pos);
540    spatial.remove_edge(edge_id, start_pos, end_pos);
541    spatial.insert_node(mid_node, split_pos);
542    spatial.insert_edge(ea, start_pos, split_pos);
543    spatial.insert_edge(eb, split_pos, end_pos);
544    mid_node
545}
546
547/// Checks whether the segment `from -> to` crosses any active edge not
548/// incident to `from_node` or `to_node`. Returns the closest crossing if
549/// one exists.
550fn find_crossing(
551    graph: &RoadGraph,
552    spatial: &SpatialHash,
553    from: Vec2,
554    to: Vec2,
555    from_node: u32,
556    to_node: u32,
557) -> Option<(u32, Vec2)> {
558    use crate::geometry::segment_intersection;
559
560    let edge_ids = spatial.edges_in_region(from, to, 0.0);
561    let mut best: Option<(u32, Vec2)> = None;
562    let mut best_dist = f32::MAX;
563
564    for e_id in edge_ids {
565        let edge = &graph.edges[e_id as usize];
566        if !edge.active {
567            continue;
568        }
569        if edge.start == from_node || edge.end == from_node {
570            continue;
571        }
572        if edge.start == to_node || edge.end == to_node {
573            continue;
574        }
575        let e_start = graph.nodes[edge.start as usize].position;
576        let e_end = graph.nodes[edge.end as usize].position;
577        if let Some(pt) = segment_intersection(from, to, e_start, e_end) {
578            let d = from.distance_squared(pt);
579            if d < best_dist {
580                best_dist = d;
581                best = Some((e_id, pt));
582            }
583        }
584    }
585    best
586}