Skip to main content

symbios_tensor/
streaming.rs

1//! On-demand tile-based city streaming for unbounded worlds.
2//!
3//! The full city pipeline (`generate_roads` → `rationalize_graph` →
4//! `extract_blocks` → `extract_lots`) keeps the entire road graph in
5//! memory, which is fine for fixed-size worlds but doesn't scale to
6//! 10×10 km open-world regions. [`CityStreamer`] divides the world into
7//! square tiles, runs the pipeline per tile on demand, caches the
8//! results, and lets the caller evict distant tiles when memory grows.
9//!
10//! # Per-tile coordinate system
11//!
12//! Each tile is a square of side `tile_size` world units. Tile `(i, j)`
13//! occupies world AABB `[i·tile_size, (i+1)·tile_size] × [j·tile_size,
14//! (j+1)·tile_size]`. The caller supplies a heightmap callback that
15//! returns a [`HeightMap`] in **tile-local coordinates** (i.e. world
16//! coordinates `[0, tile_size]` along each axis). The streamer offsets
17//! the resulting graph nodes back into world space before returning.
18//!
19//! # Seamlessness
20//!
21//! Each tile is generated with an independent tensor field over its own
22//! heightmap. The tensor field itself is locally consistent because it
23//! samples the same global heightmap (assuming the caller's callback is
24//! consistent across tile boundaries), but **streamlines do not extend
25//! across tile borders** — a road approaching the edge of tile `(i, j)`
26//! ends there, and a separately-seeded road in tile `(i+1, j)` may or
27//! may not align with it. Truly seamless cross-tile tracing is research
28//! work and out of scope here. For most consumers this is acceptable
29//! because tile borders are designed to fall on natural seams (rivers,
30//! highway corridors) or are simply hidden by mesh LOD at distance.
31
32use std::collections::HashMap;
33
34use glam::Vec2;
35use symbios_ground::HeightMap;
36
37use crate::graph::RoadGraph;
38use crate::lots::{BuildingLot, LotConfig, extract_lots};
39use crate::polygons::extract_blocks;
40use crate::rationalize::{RationalizeConfig, rationalize_graph};
41use crate::tracer::{GenerationError, TensorConfig, generate_roads};
42
43/// Configuration for a [`CityStreamer`].
44#[derive(Debug, Clone)]
45pub struct CityStreamerConfig {
46    /// Side length of each tile in world units. All tiles are square.
47    /// Must be positive.
48    pub tile_size: f32,
49    /// Base seed mixed with tile coordinates to derive per-tile seeds.
50    /// Reproducibility: the same `base_seed` + tile coordinate always
51    /// yields the same tile content.
52    pub base_seed: u64,
53    /// Tensor / road generation parameters. The `seed` field is overwritten
54    /// per-tile; the `water_level` and `field` are forwarded as-is.
55    pub tensor: TensorConfig,
56    /// Graph rationalization parameters.
57    pub rationalize: RationalizeConfig,
58    /// Lot extraction parameters.
59    pub lots: LotConfig,
60}
61
62/// One generated city tile: graph + lots + the heightmap that produced
63/// them. Node positions are in **world** coordinates (offset by the
64/// tile's origin).
65#[derive(Debug, Clone)]
66pub struct CityTile {
67    /// Integer tile index along X.
68    pub tile_x: i32,
69    /// Integer tile index along Z.
70    pub tile_z: i32,
71    /// World-space origin of this tile (lower-left corner).
72    pub origin: Vec2,
73    /// Side length copied from [`CityStreamerConfig::tile_size`].
74    pub size: f32,
75    /// Road graph in **world** coordinates. Block perimeters are valid.
76    pub graph: RoadGraph,
77    /// Building lots in **world** coordinates.
78    pub lots: Vec<BuildingLot>,
79    /// The (potentially carved-by-lot-flush) heightmap used to generate
80    /// this tile, kept for downstream meshing.
81    pub heightmap: HeightMap,
82}
83
84/// Streams city tiles on demand.
85///
86/// `P` produces a heightmap for tile `(tile_x, tile_z)` whose world
87/// coordinates run from `0` to `config.tile_size` along each axis.
88/// Returning a heightmap of any other size is a programmer error; the
89/// streamer asserts on world-size mismatch.
90pub struct CityStreamer<P>
91where
92    P: FnMut(i32, i32) -> HeightMap,
93{
94    config: CityStreamerConfig,
95    provider: P,
96    cache: HashMap<(i32, i32), CityTile>,
97}
98
99impl<P> CityStreamer<P>
100where
101    P: FnMut(i32, i32) -> HeightMap,
102{
103    /// Creates a new streamer.
104    ///
105    /// # Panics
106    ///
107    /// Panics if `config.tile_size <= 0.0` or non-finite.
108    pub fn new(config: CityStreamerConfig, provider: P) -> Self {
109        assert!(
110            config.tile_size.is_finite() && config.tile_size > 0.0,
111            "tile_size must be positive and finite, got {}",
112            config.tile_size
113        );
114        Self {
115            config,
116            provider,
117            cache: HashMap::new(),
118        }
119    }
120
121    /// Returns the configured tile size.
122    pub fn tile_size(&self) -> f32 {
123        self.config.tile_size
124    }
125
126    /// Number of tiles currently cached in memory.
127    pub fn cached_tile_count(&self) -> usize {
128        self.cache.len()
129    }
130
131    /// Returns the integer tile coordinate that contains `world_pos`.
132    pub fn tile_coord_for(&self, world_pos: Vec2) -> (i32, i32) {
133        let s = self.config.tile_size;
134        (
135            (world_pos.x / s).floor() as i32,
136            (world_pos.y / s).floor() as i32,
137        )
138    }
139
140    /// Generates tile `(tile_x, tile_z)` if not cached and returns a
141    /// reference to it.
142    pub fn ensure_tile(&mut self, tile_x: i32, tile_z: i32) -> Result<&CityTile, GenerationError> {
143        if !self.cache.contains_key(&(tile_x, tile_z)) {
144            let tile = self.generate_tile(tile_x, tile_z)?;
145            self.cache.insert((tile_x, tile_z), tile);
146        }
147        Ok(&self.cache[&(tile_x, tile_z)])
148    }
149
150    /// Ensures every tile overlapping the AABB `[min, max]` is generated
151    /// and returns references to all of them in unspecified order.
152    pub fn query_region(
153        &mut self,
154        min: Vec2,
155        max: Vec2,
156    ) -> Result<Vec<&CityTile>, GenerationError> {
157        let s = self.config.tile_size;
158        if !min.x.is_finite() || !min.y.is_finite() || !max.x.is_finite() || !max.y.is_finite() {
159            return Ok(Vec::new());
160        }
161        let tx_min = (min.x / s).floor() as i32;
162        let tz_min = (min.y / s).floor() as i32;
163        let tx_max = ((max.x / s).floor() as i32).max(tx_min);
164        let tz_max = ((max.y / s).floor() as i32).max(tz_min);
165
166        for tz in tz_min..=tz_max {
167            for tx in tx_min..=tx_max {
168                self.ensure_tile(tx, tz)?;
169            }
170        }
171
172        let mut out = Vec::new();
173        for tz in tz_min..=tz_max {
174            for tx in tx_min..=tx_max {
175                if let Some(t) = self.cache.get(&(tx, tz)) {
176                    out.push(t);
177                }
178            }
179        }
180        Ok(out)
181    }
182
183    /// Evicts every cached tile whose center is farther than
184    /// `max_distance` from `center`. Returns the number of tiles evicted.
185    pub fn evict_outside(&mut self, center: Vec2, max_distance: f32) -> usize {
186        let s = self.config.tile_size;
187        let max_sq = max_distance * max_distance;
188        let to_remove: Vec<(i32, i32)> = self
189            .cache
190            .keys()
191            .filter(|&&(tx, tz)| {
192                let tile_center = Vec2::new((tx as f32 + 0.5) * s, (tz as f32 + 0.5) * s);
193                tile_center.distance_squared(center) > max_sq
194            })
195            .copied()
196            .collect();
197        let n = to_remove.len();
198        for k in to_remove {
199            self.cache.remove(&k);
200        }
201        n
202    }
203
204    fn generate_tile(&mut self, tile_x: i32, tile_z: i32) -> Result<CityTile, GenerationError> {
205        let mut heightmap = (self.provider)(tile_x, tile_z);
206        let s = self.config.tile_size;
207        let provided_w = heightmap.world_width();
208        let provided_d = heightmap.world_depth();
209        debug_assert!(
210            (provided_w - s).abs() < 1e-3 && (provided_d - s).abs() < 1e-3,
211            "heightmap_provider returned heightmap of {provided_w}x{provided_d} world units, expected {s}x{s}",
212        );
213
214        let mut tensor_config = self.config.tensor.clone();
215        tensor_config.seed = mix_seed(self.config.base_seed, tile_x, tile_z);
216
217        let mut graph = generate_roads(&heightmap, &tensor_config)?;
218        rationalize_graph(&mut graph, &heightmap, &self.config.rationalize);
219        extract_blocks(&mut graph);
220        let lots = extract_lots(&graph, &mut heightmap, &self.config.lots);
221
222        // Translate everything from tile-local to world coordinates.
223        let origin = Vec2::new(tile_x as f32 * s, tile_z as f32 * s);
224        for node in &mut graph.nodes {
225            node.position += origin;
226        }
227        let mut lots = lots;
228        for lot in &mut lots {
229            lot.position += origin;
230            lot.frontage_center += origin;
231        }
232
233        Ok(CityTile {
234            tile_x,
235            tile_z,
236            origin,
237            size: s,
238            graph,
239            lots,
240            heightmap,
241        })
242    }
243}
244
245/// Mixes a base seed with integer tile coordinates to produce a
246/// deterministic per-tile seed. Uses splitmix64-style avalanche so that
247/// adjacent tiles have uncorrelated seeds.
248fn mix_seed(base: u64, tile_x: i32, tile_z: i32) -> u64 {
249    let x = tile_x as i64 as u64;
250    let z = tile_z as i64 as u64;
251    let mut h = base ^ x.wrapping_mul(0x9E3779B97F4A7C15);
252    h = h.wrapping_add(z.wrapping_mul(0xBF58476D1CE4E5B9));
253    h ^= h >> 30;
254    h = h.wrapping_mul(0xBF58476D1CE4E5B9);
255    h ^= h >> 27;
256    h = h.wrapping_mul(0x94D049BB133111EB);
257    h ^ (h >> 31)
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    fn make_streamer() -> CityStreamer<impl FnMut(i32, i32) -> HeightMap> {
265        let cfg = CityStreamerConfig {
266            tile_size: 32.0,
267            base_seed: 7,
268            tensor: TensorConfig {
269                step_size: 1.0,
270                major_road_dist: 12.0,
271                minor_road_dist: 6.0,
272                snap_radius: 2.0,
273                max_trace_steps: 80,
274                ..Default::default()
275            },
276            rationalize: RationalizeConfig::default(),
277            lots: LotConfig::default(),
278        };
279
280        // Heightmap provider: a simple slope that varies with tile so
281        // each tile's tensor field is non-trivial.
282        let provider = move |tile_x: i32, tile_z: i32| {
283            let mut hm = HeightMap::new(32, 32, 1.0);
284            for z in 0..32 {
285                for x in 0..32 {
286                    let global_x = tile_x as f32 * 32.0 + x as f32;
287                    let global_z = tile_z as f32 * 32.0 + z as f32;
288                    let h = (global_x * 0.05).sin() * 2.0 + (global_z * 0.07).cos();
289                    hm.set(x, z, h);
290                }
291            }
292            hm
293        };
294
295        CityStreamer::new(cfg, provider)
296    }
297
298    #[test]
299    fn ensure_tile_caches() {
300        let mut s = make_streamer();
301        assert_eq!(s.cached_tile_count(), 0);
302        s.ensure_tile(0, 0).expect("generate (0,0)");
303        assert_eq!(s.cached_tile_count(), 1);
304        // Re-requesting same tile must not regenerate.
305        s.ensure_tile(0, 0).expect("re-request (0,0)");
306        assert_eq!(s.cached_tile_count(), 1);
307    }
308
309    #[test]
310    fn tile_nodes_in_world_coordinates() {
311        let mut s = make_streamer();
312        let tile = s.ensure_tile(2, 3).expect("generate").clone();
313        let origin = tile.origin;
314        let size = tile.size;
315        for node in &tile.graph.nodes {
316            assert!(
317                node.position.x >= origin.x - 1e-3
318                    && node.position.x <= origin.x + size + 1e-3
319                    && node.position.y >= origin.y - 1e-3
320                    && node.position.y <= origin.y + size + 1e-3,
321                "node at {:?} outside tile (origin={origin:?}, size={size})",
322                node.position
323            );
324        }
325    }
326
327    #[test]
328    fn query_region_spans_multiple_tiles() {
329        let mut s = make_streamer();
330        let tiles = s
331            .query_region(Vec2::new(-10.0, -10.0), Vec2::new(50.0, 50.0))
332            .expect("query_region");
333        // The AABB spans tiles (-1, -1), (0, -1), (-1, 0), (0, 0), (1, 0),
334        // (0, 1), (1, 1), (-1, 1), (1, -1) — all 9 of the 3×3 block.
335        assert_eq!(tiles.len(), 9, "expected 9 tiles, got {}", tiles.len());
336    }
337
338    #[test]
339    fn evict_outside_drops_far_tiles() {
340        let mut s = make_streamer();
341        s.ensure_tile(0, 0).expect("(0,0)");
342        s.ensure_tile(5, 5).expect("(5,5)");
343        assert_eq!(s.cached_tile_count(), 2);
344
345        // (0,0) center is at (16, 16); (5,5) center is at (176, 176).
346        // Evict everything farther than 100 units from (16, 16).
347        let evicted = s.evict_outside(Vec2::new(16.0, 16.0), 100.0);
348        assert_eq!(evicted, 1);
349        assert_eq!(s.cached_tile_count(), 1);
350    }
351
352    #[test]
353    fn deterministic_seeds_per_tile() {
354        let mut s1 = make_streamer();
355        let mut s2 = make_streamer();
356        let t1 = s1.ensure_tile(3, 4).unwrap().clone();
357        let t2 = s2.ensure_tile(3, 4).unwrap().clone();
358        assert_eq!(t1.graph.nodes.len(), t2.graph.nodes.len());
359        for (a, b) in t1.graph.nodes.iter().zip(t2.graph.nodes.iter()) {
360            assert!((a.position - b.position).length() < 1e-5);
361        }
362    }
363}