Skip to main content

symbios_tensor/
tensor.rs

1//! Tensor field derived from heightmap surface normals.
2//!
3//! The field decomposes each surface normal into orthogonal 2D directions:
4//! a **major** (contour) axis that follows elevation lines and a **minor**
5//! (gradient) axis that points up- or down-slope. Road traces integrate
6//! along these axes to produce terrain-adaptive street layouts.
7//!
8//! On near-flat terrain the field smoothly blends toward an axis-aligned
9//! Manhattan grid as slope drops through `[flat_threshold_low,
10//! flat_threshold_high]`; an optional low-frequency jitter perturbs the
11//! field to break up perfectly parallel streamlines.
12
13use std::sync::atomic::{AtomicBool, Ordering};
14
15use glam::{Vec2, Vec3};
16use serde::{Deserialize, Serialize};
17use symbios_ground::HeightMap;
18
19/// Configuration for [`TensorField`] sampling.
20///
21/// Slope (the magnitude of the normal's XZ projection) controls a smooth
22/// blend between terrain-derived directions and an axis-aligned fallback,
23/// avoiding the abrupt regime change at near-zero slopes that produced
24/// visible artefacts at the boundary.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct TensorFieldConfig {
27    /// Slope at or below which the field returns the pure axis-aligned
28    /// fallback. Default: `1e-4`.
29    pub flat_threshold_low: f32,
30    /// Slope at or above which the field returns pure terrain-derived
31    /// directions. Default: `1e-3`.
32    pub flat_threshold_high: f32,
33    /// Amplitude of low-frequency directional jitter, in radians. Small
34    /// values (e.g. `0.05`–`0.2`) break perfectly parallel streamlines on
35    /// flat terrain. Default: `0.0` (disabled).
36    pub jitter_amplitude: f32,
37    /// Spatial frequency of the jitter (cycles per world unit). Lower
38    /// values produce larger, gentler swirls. Default: `0.01`.
39    pub jitter_frequency: f32,
40}
41
42impl Default for TensorFieldConfig {
43    fn default() -> Self {
44        Self {
45            flat_threshold_low: 1e-4,
46            flat_threshold_high: 1e-3,
47            jitter_amplitude: 0.0,
48            jitter_frequency: 0.01,
49        }
50    }
51}
52
53/// Evaluates a tensor field over a [`HeightMap`], producing orthogonal major/minor
54/// direction vectors at any world-space coordinate.
55///
56/// - **Major** (contour): follows elevation lines — ideal for winding mountain roads.
57/// - **Minor** (gradient): points up/down the slope — ideal for steep connecting streets.
58pub struct TensorField<'a> {
59    pub(crate) heightmap: &'a HeightMap,
60    config: TensorFieldConfig,
61    fallback_warned: AtomicBool,
62}
63
64impl<'a> TensorField<'a> {
65    /// Creates a tensor field with default sampling parameters.
66    pub fn new(heightmap: &'a HeightMap) -> Self {
67        Self::with_config(heightmap, TensorFieldConfig::default())
68    }
69
70    /// Creates a tensor field with custom sampling parameters.
71    pub fn with_config(heightmap: &'a HeightMap, config: TensorFieldConfig) -> Self {
72        Self {
73            heightmap,
74            config,
75            fallback_warned: AtomicBool::new(false),
76        }
77    }
78
79    /// Samples the tensor field, returning `(major, minor)` unit direction vectors.
80    pub fn sample(&self, world_x: f32, world_z: f32) -> (Vec2, Vec2) {
81        let n_arr = self.heightmap.get_normal_at(world_x, world_z);
82        let normal = Vec3::from_array(n_arr);
83
84        // Minor axis: projection of surface normal onto the XZ plane (gradient direction).
85        // Its magnitude is the slope (steepness).
86        let raw_minor = Vec2::new(normal.x, normal.z);
87        let slope = raw_minor.length();
88
89        let cfg = &self.config;
90        let t = smoothstep(cfg.flat_threshold_low, cfg.flat_threshold_high, slope);
91
92        if t < 1.0
93            && self
94                .fallback_warned
95                .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
96                .is_ok()
97        {
98            eprintln!(
99                "symbios-tensor: tensor field blending toward axis-aligned fallback on near-flat terrain (slope={slope:.3e})"
100            );
101        }
102
103        // Stable minor direction. On true-zero slope, default to +Z; otherwise
104        // blend the terrain direction toward the nearest axis as t→0 to avoid
105        // sign-flip artefacts when the gradient direction is unstable.
106        let blended_minor = if slope > 1e-12 {
107            let terrain_dir = raw_minor / slope;
108            let fallback_dir = nearest_axis(terrain_dir);
109            (terrain_dir * t + fallback_dir * (1.0 - t)).normalize_or_zero()
110        } else {
111            Vec2::new(0.0, 1.0)
112        };
113
114        // Apply low-frequency directional jitter (deterministic per world point).
115        let minor = if cfg.jitter_amplitude.abs() > 0.0 {
116            let phase = world_x * cfg.jitter_frequency + world_z * cfg.jitter_frequency * 1.7320508;
117            let angle = cfg.jitter_amplitude * phase.sin();
118            rotate(blended_minor, angle)
119        } else {
120            blended_minor
121        };
122
123        let minor = if minor.length_squared() < 1e-12 {
124            Vec2::new(0.0, 1.0)
125        } else {
126            minor.normalize()
127        };
128        let major = Vec2::new(-minor.y, minor.x);
129        (major, minor)
130    }
131}
132
133fn smoothstep(edge0: f32, edge1: f32, x: f32) -> f32 {
134    if edge1 <= edge0 {
135        return if x >= edge1 { 1.0 } else { 0.0 };
136    }
137    let t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
138    t * t * (3.0 - 2.0 * t)
139}
140
141/// Returns the unit axis vector (`±X` or `±Z`) closest to `dir`. Caller
142/// must pass a non-zero direction.
143fn nearest_axis(dir: Vec2) -> Vec2 {
144    if dir.x.abs() >= dir.y.abs() {
145        Vec2::new(if dir.x >= 0.0 { 1.0 } else { -1.0 }, 0.0)
146    } else {
147        Vec2::new(0.0, if dir.y >= 0.0 { 1.0 } else { -1.0 })
148    }
149}
150
151fn rotate(v: Vec2, angle: f32) -> Vec2 {
152    let (s, c) = angle.sin_cos();
153    Vec2::new(v.x * c - v.y * s, v.x * s + v.y * c)
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn flat_terrain_consistent_direction() {
162        // On a perfectly flat heightmap every sample must return the same
163        // axis-aligned direction (no split between two regimes).
164        let hm = HeightMap::new(64, 64, 2.0);
165        let field = TensorField::new(&hm);
166
167        let (m0, n0) = field.sample(10.0, 10.0);
168        for x in (0..120).step_by(7) {
169            for z in (0..120).step_by(11) {
170                let (m, n) = field.sample(x as f32, z as f32);
171                assert!(
172                    (m - m0).length() < 1e-5 && (n - n0).length() < 1e-5,
173                    "flat-terrain sample at ({x}, {z}) drifted: major={m:?} expected {m0:?}, minor={n:?} expected {n0:?}"
174                );
175            }
176        }
177    }
178
179    #[test]
180    fn smooth_blend_no_cliff() {
181        // Build a heightmap with a controlled slope and verify the blend
182        // function returns directions that smoothly transition rather than
183        // snapping at a single threshold.
184        let mut hm = HeightMap::new(32, 32, 1.0);
185        // Very gentle slope: 0.0005 * x — slope magnitude ~0.0005, in the
186        // middle of the default blend window (1e-4 .. 1e-3).
187        for z in 0..32 {
188            for x in 0..32 {
189                hm.set(x, z, x as f32 * 0.0005);
190            }
191        }
192        let field = TensorField::new(&hm);
193        let (_major, minor) = field.sample(15.0, 15.0);
194        // Under the old binary fallback the result would be either the pure
195        // terrain direction or the fallback. Under smooth blend, the minor
196        // direction is a blend that points roughly along +X (the slope) but
197        // is biased toward an axis. Just assert it's a valid unit vector.
198        assert!(
199            (minor.length() - 1.0).abs() < 1e-4,
200            "minor must be unit, got {minor:?}"
201        );
202    }
203
204    #[test]
205    fn jitter_breaks_flat_uniformity() {
206        // With jitter enabled on a perfectly flat heightmap, samples at
207        // different world points must yield different directions, breaking
208        // up the perfectly parallel streamlines that flat terrain would
209        // otherwise produce.
210        let hm = HeightMap::new(64, 64, 2.0);
211        let cfg = TensorFieldConfig {
212            jitter_amplitude: 0.3,
213            jitter_frequency: 0.05,
214            ..Default::default()
215        };
216        let field = TensorField::with_config(&hm, cfg);
217
218        let (_, n0) = field.sample(10.0, 10.0);
219        let (_, n1) = field.sample(50.0, 50.0);
220        assert!(
221            (n0 - n1).length() > 1e-3,
222            "jitter must produce distinct directions; got {n0:?} and {n1:?}"
223        );
224    }
225
226    #[test]
227    fn major_minor_orthogonal() {
228        let mut hm = HeightMap::new(16, 16, 1.0);
229        for z in 0..16 {
230            for x in 0..16 {
231                hm.set(x, z, (x + z) as f32 * 0.1);
232            }
233        }
234        let field = TensorField::new(&hm);
235        let (m, n) = field.sample(8.0, 8.0);
236        assert!(
237            m.dot(n).abs() < 1e-4,
238            "major and minor must be orthogonal: m={m:?}, n={n:?}, dot={}",
239            m.dot(n)
240        );
241    }
242}